"
break_counter = 0
for(var/job in long_job_lists[department])
if(break_counter > 0 && (break_counter % 10 == 0))
diff --git a/code/modules/admin/verbs/adminhelp.dm b/code/modules/admin/verbs/adminhelp.dm
index d2e14d6c068f6..e7b6dfdf955af 100644
--- a/code/modules/admin/verbs/adminhelp.dm
+++ b/code/modules/admin/verbs/adminhelp.dm
@@ -206,7 +206,7 @@ GLOBAL_DATUM_INIT(ahelp_tickets, /datum/help_tickets/admin, new)
//send this msg to all admins
for(var/client/X in GLOB.admins)
- if(X.prefs.toggles & PREFTOGGLE_SOUND_ADMINHELP)
+ if(X.prefs.read_player_preference(/datum/preference/toggle/sound_adminhelp))
SEND_SOUND(X, sound(reply_sound))
window_flash(X, ignorepref = TRUE)
to_chat(X,
diff --git a/code/modules/admin/verbs/adminpm.dm b/code/modules/admin/verbs/adminpm.dm
index 5699c1b59010c..a5dde9831d127 100644
--- a/code/modules/admin/verbs/adminpm.dm
+++ b/code/modules/admin/verbs/adminpm.dm
@@ -193,7 +193,7 @@
to_chat(src, "
PM to-Admins: [msg]", type = MESSAGE_TYPE_ADMINPM)
//play the receiving admin the adminhelp sound (if they have them enabled)
- if(recipient.prefs.toggles & PREFTOGGLE_SOUND_ADMINHELP)
+ if(recipient.prefs.read_player_preference(/datum/preference/toggle/sound_adminhelp))
SEND_SOUND(recipient, sound('sound/effects/adminhelp.ogg'))
else
diff --git a/code/modules/admin/verbs/adminsay.dm b/code/modules/admin/verbs/adminsay.dm
index 7cf7bd128eea6..390b0b570b18d 100644
--- a/code/modules/admin/verbs/adminsay.dm
+++ b/code/modules/admin/verbs/adminsay.dm
@@ -15,7 +15,8 @@
mob.log_talk(msg, LOG_ASAY)
msg = keywords_lookup(msg)
- var/custom_asay_color = (CONFIG_GET(flag/allow_admin_asaycolor) && prefs.asaycolor) ? "
" : ""
+ var/asay_color = prefs.read_player_preference(/datum/preference/color/asay_color)
+ var/custom_asay_color = (CONFIG_GET(flag/allow_admin_asaycolor) && asay_color) ? "" : ""
msg = "ADMIN: [key_name(usr, 1)] [ADMIN_FLW(mob)]: [custom_asay_color][msg][custom_asay_color ? "":null]"
to_chat(GLOB.admins, msg, allow_linkify = TRUE, type = MESSAGE_TYPE_ADMINCHAT)
diff --git a/code/modules/admin/verbs/deadsay.dm b/code/modules/admin/verbs/deadsay.dm
index 793b052fb1dab..5b05509de5113 100644
--- a/code/modules/admin/verbs/deadsay.dm
+++ b/code/modules/admin/verbs/deadsay.dm
@@ -31,7 +31,7 @@
for (var/mob/M in GLOB.player_list)
if(isnewplayer(M))
continue
- if (M.stat == DEAD || (M.client && M.client.holder && (M.client.prefs.chat_toggles & CHAT_DEAD))) //admins can toggle deadchat on and off. This is a proc in admin.dm and is only give to Administrators and above
+ if (M.stat == DEAD || (M.client && M.client.holder && M.client.prefs.read_player_preference(/datum/preference/toggle/chat_dead))) //admins can toggle deadchat on and off. This is a proc in admin.dm and is only give to Administrators and above
to_chat(M, rendered)
SSblackbox.record_feedback("tally", "admin_verb", 1, "Dsay") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
diff --git a/code/modules/admin/verbs/mentorhelp.dm b/code/modules/admin/verbs/mentorhelp.dm
index 9b187bc9b121c..7472b36f7e7db 100644
--- a/code/modules/admin/verbs/mentorhelp.dm
+++ b/code/modules/admin/verbs/mentorhelp.dm
@@ -163,7 +163,7 @@ GLOBAL_DATUM_INIT(mhelp_tickets, /datum/help_tickets/mentor, new)
//send this msg to all admins
for(var/client/X in GLOB.mentors | GLOB.admins)
- if(X.prefs.toggles & PREFTOGGLE_SOUND_ADMINHELP)
+ if(X.prefs.read_player_preference(/datum/preference/toggle/sound_adminhelp))
SEND_SOUND(X, sound(reply_sound))
window_flash(X, ignorepref = TRUE)
to_chat(X, admin_msg, type = message_type)
diff --git a/code/modules/admin/verbs/one_click_antag.dm b/code/modules/admin/verbs/one_click_antag.dm
index 59828b52a9e62..e2574d2c5e6e1 100644
--- a/code/modules/admin/verbs/one_click_antag.dm
+++ b/code/modules/admin/verbs/one_click_antag.dm
@@ -379,7 +379,7 @@
//Spawn the body
var/mob/living/carbon/human/ERTOperative = new ertemplate.mobtype(spawnloc)
- chosen_candidate.client.prefs.active_character.copy_to(ERTOperative)
+ chosen_candidate.client.prefs.safe_transfer_prefs_to(ERTOperative, is_antag = TRUE)
ERTOperative.key = chosen_candidate.key
log_objective(ERTOperative, missionobj.explanation_text)
diff --git a/code/modules/admin/verbs/playsound.dm b/code/modules/admin/verbs/playsound.dm
index 951f7c164be43..f08ee0db993e5 100644
--- a/code/modules/admin/verbs/playsound.dm
+++ b/code/modules/admin/verbs/playsound.dm
@@ -36,7 +36,7 @@
message_admins("[key_name_admin(src)] played sound [S]")
for(var/mob/M in GLOB.player_list)
- if(M.client.prefs.toggles & PREFTOGGLE_SOUND_MIDI)
+ if(M.client.prefs.read_player_preference(/datum/preference/toggle/sound_midi))
admin_sound.volume = vol * M.client.admin_music_volume
SEND_SOUND(M, admin_sound)
admin_sound.volume = vol
@@ -142,7 +142,7 @@
for(var/m in GLOB.player_list)
var/mob/M = m
var/client/C = M.client
- if(C.prefs.toggles & PREFTOGGLE_SOUND_MIDI)
+ if(C.prefs.read_player_preference(/datum/preference/toggle/sound_midi))
if(!stop_web_sounds)
C.tgui_panel?.play_music(web_sound_url, music_extra_data)
else
diff --git a/code/modules/admin/verbs/pray.dm b/code/modules/admin/verbs/pray.dm
index 6bbffe9f710cd..49f8cb1326e95 100644
--- a/code/modules/admin/verbs/pray.dm
+++ b/code/modules/admin/verbs/pray.dm
@@ -44,7 +44,7 @@
msg = "[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)
+ if(C.prefs.read_player_preference(/datum/preference/toggle/chat_prayer))
to_chat(C, msg)
to_chat(usr, "You pray to the gods: \"[msg_tmp]\"")
diff --git a/code/modules/admin/verbs/randomverbs.dm b/code/modules/admin/verbs/randomverbs.dm
index fd7cb38831f5b..1807c8b20b851 100644
--- a/code/modules/admin/verbs/randomverbs.dm
+++ b/code/modules/admin/verbs/randomverbs.dm
@@ -432,10 +432,10 @@ Traitors and the like can also be revived with the previous role mostly intact.
new_character.age = record_found.fields["age"]
new_character.hardset_dna(record_found.fields["identity"], record_found.fields["enzymes"], record_found.fields["name"], record_found.fields["blood_type"], new record_found.fields["species"], record_found.fields["features"], null)
else
- var/datum/character_save/CS = new()
- CS.randomise()
- CS.pref_species.random_name(CS.gender, TRUE)
- CS.copy_to(new_character)
+ // TODO tgui-prefs test
+ randomize_human(new_character)
+ new_character.real_name = new_character.dna.species.random_name(new_character.gender, TRUE)
+ new_character.name = new_character.real_name
new_character.dna.update_dna_identity()
new_character.name = new_character.real_name
@@ -881,7 +881,7 @@ Traitors and the like can also be revived with the previous role mostly intact.
for(var/datum/atom_hud/antag/H in GLOB.huds) // add antag huds
(adding_hud) ? H.add_hud_to(usr) : H.remove_hud_from(usr)
- if(prefs.toggles & PREFTOGGLE_COMBOHUD_LIGHTING)
+ if(prefs?.read_player_preference(/datum/preference/toggle/combohud_lighting))
if(adding_hud)
mob.lighting_alpha = LIGHTING_PLANE_ALPHA_INVISIBLE
else
diff --git a/code/modules/antagonists/_common/antag_datum.dm b/code/modules/antagonists/_common/antag_datum.dm
index b2178beaf20d9..ebb79c38e58e9 100644
--- a/code/modules/antagonists/_common/antag_datum.dm
+++ b/code/modules/antagonists/_common/antag_datum.dm
@@ -122,7 +122,7 @@ GLOBAL_LIST(admin_antag_list)
give_antag_moodies()
if(is_banned(owner.current) && replace_banned)
replace_banned_player()
- else if(owner.current.client?.holder && (CONFIG_GET(flag/auto_deadmin_antagonists) || owner.current.client.prefs?.toggles & PREFTOGGLE_DEADMIN_ANTAGONIST))
+ else if(owner.current.client?.holder && (CONFIG_GET(flag/auto_deadmin_antagonists) || owner.current.client.prefs?.read_player_preference(/datum/preference/toggle/deadmin_antagonist)))
owner.current.client.holder.auto_deadmin()
if(count_against_dynamic_roll_chance && owner.current.stat != DEAD && owner.current.client)
owner.current.add_to_current_living_antags()
diff --git a/code/modules/antagonists/_common/antag_spawner.dm b/code/modules/antagonists/_common/antag_spawner.dm
index d2812cb7ecd90..75e748de05d45 100644
--- a/code/modules/antagonists/_common/antag_spawner.dm
+++ b/code/modules/antagonists/_common/antag_spawner.dm
@@ -74,7 +74,7 @@
/obj/item/antag_spawner/contract/spawn_antag(client/C, turf/T, kind ,datum/mind/user)
new /obj/effect/particle_effect/smoke(T)
var/mob/living/carbon/human/M = new/mob/living/carbon/human(T)
- C.prefs.active_character.copy_to(M)
+ C.prefs.apply_prefs_to(M)
M.key = C.key
var/datum/mind/app_mind = M.mind
@@ -134,7 +134,7 @@
/obj/item/antag_spawner/nuke_ops/spawn_antag(client/C, turf/T, kind, datum/mind/user)
var/mob/living/carbon/human/M = new/mob/living/carbon/human(T)
- C.prefs.active_character.copy_to(M)
+ C.prefs.apply_prefs_to(M)
M.key = C.key
var/datum/antagonist/nukeop/new_op = new()
@@ -153,7 +153,7 @@
/obj/item/antag_spawner/nuke_ops/clown/spawn_antag(client/C, turf/T, kind, datum/mind/user)
var/mob/living/carbon/human/M = new/mob/living/carbon/human(T)
- C.prefs.active_character.copy_to(M)
+ C.prefs.apply_prefs_to(M)
M.key = C.key
var/datum/antagonist/nukeop/clownop/new_op = new /datum/antagonist/nukeop/clownop()
@@ -315,7 +315,7 @@
/obj/item/antag_spawner/gangster/spawn_antag(client/C, turf/T, datum/mind/user)
var/mob/living/carbon/human/M = new/mob/living/carbon/human(T)
if (C)
- C.prefs.active_character.copy_to(M)
+ C.prefs.apply_prefs_to(M)
M.key = C.key
var/datum/antagonist/gang/alignment = user.has_antag_datum(/datum/antagonist/gang,TRUE)
diff --git a/code/modules/antagonists/abductor/abductor.dm b/code/modules/antagonists/abductor/abductor.dm
index 0e141346f5cec..a7a0854c31ca2 100644
--- a/code/modules/antagonists/abductor/abductor.dm
+++ b/code/modules/antagonists/abductor/abductor.dm
@@ -13,7 +13,6 @@
var/landmark_type
var/greet_text
-
/datum/antagonist/abductor/agent
name = "Abductor Agent"
sub_role = "Agent"
diff --git a/code/modules/antagonists/abductor/equipment/gland.dm b/code/modules/antagonists/abductor/equipment/gland.dm
index 7a4a1f1ac856b..6f9badfb740cb 100644
--- a/code/modules/antagonists/abductor/equipment/gland.dm
+++ b/code/modules/antagonists/abductor/equipment/gland.dm
@@ -71,7 +71,7 @@
owner.clear_alert("mind_control")
active_mind_control = FALSE
-/obj/item/organ/heart/gland/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/gland/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
active = 0
if(initial(uses) == 1)
uses = initial(uses)
@@ -131,12 +131,12 @@
mind_control_uses = 1
mind_control_duration = 2400
-/obj/item/organ/heart/gland/slime/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/gland/slime/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
owner.faction |= "slime"
owner.grant_language(/datum/language/slime, TRUE, TRUE, LANGUAGE_GLAND)
-/obj/item/organ/heart/gland/slime/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/gland/slime/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
owner.faction -= "slime"
owner.remove_language(/datum/language/slime, TRUE, TRUE, LANGUAGE_GLAND)
@@ -298,11 +298,11 @@
mind_control_uses = 2
mind_control_duration = 900
-/obj/item/organ/heart/gland/electric/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/gland/electric/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
ADD_TRAIT(owner, TRAIT_SHOCKIMMUNE, ORGAN_TRAIT)
-/obj/item/organ/heart/gland/electric/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/gland/electric/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
REMOVE_TRAIT(owner, TRAIT_SHOCKIMMUNE, ORGAN_TRAIT)
..()
diff --git a/code/modules/antagonists/changeling/changeling.dm b/code/modules/antagonists/changeling/changeling.dm
index 26e3e967bb685..be3ce6a94c11e 100644
--- a/code/modules/antagonists/changeling/changeling.dm
+++ b/code/modules/antagonists/changeling/changeling.dm
@@ -356,17 +356,13 @@
var/mob/living/carbon/C = owner.current //only carbons have dna now, so we have to typecaste
if(isipc(C))
C.set_species(/datum/species/human)
- var/replacementName = random_unique_name(C.gender)
- if(C.client.prefs.active_character.custom_names["human"])
- C.fully_replace_character_name(C.real_name, C.client.prefs.active_character.custom_names["human"])
- else
- C.fully_replace_character_name(C.real_name, replacementName)
+ C.fully_replace_character_name(C.real_name, C.client.prefs.read_character_preference(/datum/preference/name/backup_human))
for(var/datum/data/record/E in GLOB.data_core.general)
if(E.fields["name"] == C.real_name)
E.fields["species"] = "\improper Human"
var/client/Clt = C.client
var/static/list/show_directions = list(SOUTH, WEST)
- var/image = GLOB.data_core.get_id_photo(C, Clt, show_directions, TRUE)
+ var/image = GLOB.data_core.get_id_photo(C, Clt, show_directions)// TODO tgui-prefs test
var/datum/picture/pf = new
var/datum/picture/ps = new
pf.picture_name = "[C]"
diff --git a/code/modules/antagonists/clock_cult/items/brass_clothing.dm b/code/modules/antagonists/clock_cult/items/brass_clothing.dm
index a28b243622283..63fc145414b75 100644
--- a/code/modules/antagonists/clock_cult/items/brass_clothing.dm
+++ b/code/modules/antagonists/clock_cult/items/brass_clothing.dm
@@ -9,10 +9,14 @@
w_class = WEIGHT_CLASS_BULKY
body_parts_covered = CHEST|GROIN|LEGS|ARMS
allowed = list(/obj/item/clockwork, /obj/item/stack/sheet/brass, /obj/item/clockwork, /obj/item/gun/ballistic/bow/clockwork)
+ var/allow_any = FALSE
+
+/obj/item/clothing/suit/clockwork/anyone
+ allow_any = TRUE
/obj/item/clothing/suit/clockwork/equipped(mob/living/user, slot)
. = ..()
- if(!is_servant_of_ratvar(user))
+ if(!is_servant_of_ratvar(user) && !allow_any)
to_chat(user, "You feel a shock of energy surge through your body!")
user.dropItemToGround(src, TRUE)
var/mob/living/carbon/C = user
diff --git a/code/modules/antagonists/clock_cult/items/clockwork_weapon.dm b/code/modules/antagonists/clock_cult/items/clockwork_weapon.dm
index 0bfc15f918264..5906f8a4d9551 100644
--- a/code/modules/antagonists/clock_cult/items/clockwork_weapon.dm
+++ b/code/modules/antagonists/clock_cult/items/clockwork_weapon.dm
@@ -23,6 +23,8 @@
/obj/item/clockwork/weapon/pickup(mob/user)
..()
+ if(!user.mind)
+ return
user.mind.RemoveSpell(SS)
if(is_servant_of_ratvar(user))
SS = new
diff --git a/code/modules/antagonists/creep/creep.dm b/code/modules/antagonists/creep/creep.dm
index fedb5f8e8251b..1fdd86b3f6469 100644
--- a/code/modules/antagonists/creep/creep.dm
+++ b/code/modules/antagonists/creep/creep.dm
@@ -48,7 +48,7 @@
var/mob/living/M = mob_override || owner.current
update_obsession_icons_removed(M)
-/datum/antagonist/obsessed/proc/forge_objectives(var/datum/mind/obsessionmind)
+/datum/antagonist/obsessed/proc/forge_objectives(datum/mind/obsessionmind)
var/list/objectives_left = list("spendtime", "polaroid", "hug")
var/datum/objective/assassinate/obsessed/kill = new
kill.owner = owner
diff --git a/code/modules/antagonists/cult/cult.dm b/code/modules/antagonists/cult/cult.dm
index db8b0dcf1ddc4..8707208263474 100644
--- a/code/modules/antagonists/cult/cult.dm
+++ b/code/modules/antagonists/cult/cult.dm
@@ -70,7 +70,6 @@
if(cult_team.blood_target && cult_team.blood_target_image && current.client)
current.client.images += cult_team.blood_target_image
-
/datum/antagonist/cult/proc/equip_cultist(metal=TRUE)
var/mob/living/carbon/C = owner.current
if(!istype(C))
diff --git a/code/modules/antagonists/cult/cult_items.dm b/code/modules/antagonists/cult/cult_items.dm
index 54c45b721b998..ddd669818fc8d 100644
--- a/code/modules/antagonists/cult/cult_items.dm
+++ b/code/modules/antagonists/cult/cult_items.dm
@@ -392,6 +392,11 @@ Striking a noncultist, however, will tear their flesh."}
body_parts_covered = CHEST|GROIN|LEGS|ARMS
allowed = list(/obj/item/tome, /obj/item/melee/cultblade)
hoodtype = /obj/item/clothing/head/hooded/cult_hoodie
+ /// if anyone can equip this. used by the prefs menu
+ var/allow_any = FALSE
+
+/obj/item/clothing/suit/hooded/cultrobes/cult_shield/anyone
+ allow_any = TRUE
/obj/item/clothing/suit/hooded/cultrobes/cult_shield/Initialize()
. = ..()
@@ -415,7 +420,7 @@ Striking a noncultist, however, will tear their flesh."}
/obj/item/clothing/suit/hooded/cultrobes/cult_shield/equipped(mob/living/user, slot)
..()
- if(!iscultist(user))
+ if(!iscultist(user) && !allow_any)
to_chat(user, "\"I wouldn't advise that.\"")
to_chat(user, "An overwhelming sense of nausea overpowers you!")
user.dropItemToGround(src, TRUE)
diff --git a/code/modules/antagonists/ert/ert.dm b/code/modules/antagonists/ert/ert.dm
index 8271aa19dd4e5..fdfc6b97c62e6 100644
--- a/code/modules/antagonists/ert/ert.dm
+++ b/code/modules/antagonists/ert/ert.dm
@@ -38,8 +38,8 @@
/datum/antagonist/ert/proc/update_name()
var/name = pick(name_source)
if (!name)
- name = owner.current.client?.prefs.active_character.custom_names["human"] || pick(GLOB.last_names)
- owner.current.fully_replace_character_name(owner.current.real_name,"[role] [name]")
+ name = owner.current.client?.prefs.read_character_preference(/datum/preference/name/backup_human) || pick(GLOB.last_names)
+ owner.current.fully_replace_character_name(owner.current.real_name, "[role] [name]")
/datum/antagonist/ert/deathsquad/New()
. = ..()
diff --git a/code/modules/antagonists/fugitive/hunter_outfits.dm b/code/modules/antagonists/fugitive/hunter_outfits.dm
index ed9b08c8a5ff2..4078e35b564b3 100644
--- a/code/modules/antagonists/fugitive/hunter_outfits.dm
+++ b/code/modules/antagonists/fugitive/hunter_outfits.dm
@@ -38,7 +38,9 @@
mask = /obj/item/clothing/mask/gas/sechailer/spacepol
glasses = /obj/item/clothing/glasses/hud/security/sunglasses
-/datum/outfit/spacepol/officer/pre_equip(mob/living/carbon/human/H)
+/datum/outfit/spacepol/officer/pre_equip(mob/living/carbon/human/H, visualsOnly = FALSE)
+ if(visualsOnly)
+ return
if(prob(40))
head = /obj/item/clothing/head/helmet/alt
else if(prob(20))
@@ -107,7 +109,9 @@
back = /obj/item/storage/backpack/satchel/leather
box = /obj/item/storage/box/survival
-/datum/outfit/russian_hunter/pre_equip(mob/living/carbon/human/H)
+/datum/outfit/russian_hunter/pre_equip(mob/living/carbon/human/H, visualsOnly = FALSE)
+ if(visualsOnly)
+ return
if(prob(50))
head = /obj/item/clothing/head/ushanka
else if(prob(20))
@@ -128,7 +132,9 @@
suit = /obj/item/clothing/suit/security/officer/russian
head = /obj/item/clothing/head/helmet/rus_ushanka
-/datum/outfit/russian_hunter/leader/pre_equip(mob/living/carbon/human/H)
+/datum/outfit/russian_hunter/leader/pre_equip(mob/living/carbon/human/H, visualsOnly = FALSE)
+ if(visualsOnly)
+ return
if(prob(50))
gloves = /obj/item/clothing/gloves/combat
else if(prob(30))
diff --git a/code/modules/antagonists/heretic/heretic_antag.dm b/code/modules/antagonists/heretic/heretic_antag.dm
index 10b5d4c383fed..4041dde1a5484 100644
--- a/code/modules/antagonists/heretic/heretic_antag.dm
+++ b/code/modules/antagonists/heretic/heretic_antag.dm
@@ -147,11 +147,11 @@
if(isipc(owner.current))//Due to IPCs having a mechanical heart it messes with the living heart, so no IPC heretics for now
var/mob/living/carbon/C = owner.current //only carbons have dna now, so we have to typecast
C.set_species(/datum/species/human)
- var/replacementName = random_unique_name(C.gender)
- if(C.client.prefs.active_character.custom_names["human"])
- C.fully_replace_character_name(C.real_name, C.client.prefs.active_character.custom_names["human"])
+ var/prefs_name = C.client?.prefs?.read_character_preference(/datum/preference/name/backup_human)
+ if(prefs_name)
+ C.fully_replace_character_name(C.real_name, prefs_name)
else
- C.fully_replace_character_name(C.real_name, replacementName)
+ C.fully_replace_character_name(C.real_name, random_unique_name(C.gender))
if(give_objectives)
forge_objectives()
@@ -698,17 +698,6 @@
return completed || (num_we_have >= target_amount)
-/datum/outfit/heretic
- name = "Heretic (Preview only)"
-
- suit = /obj/item/clothing/suit/hooded/cultrobes/eldritch
- r_hand = /obj/item/melee/touch_attack/mansus_fist
-
-/datum/outfit/heretic/post_equip(mob/living/carbon/human/equipper, visualsOnly)
- var/obj/item/clothing/suit/hooded/hooded = locate() in equipper
- hooded.MakeHood() // This is usually created on Initialize, but we run before atoms
- hooded.ToggleHood()
-
/datum/action/innate/hereticmenu
name = "Forbidden Knowledge"
desc = "Utilize your connection to the beyond to unlock new eldritch abilities"
diff --git a/code/modules/antagonists/role_preference/_role_preference.dm b/code/modules/antagonists/role_preference/_role_preference.dm
index 710ce5d2430d6..37d8cc2437a16 100644
--- a/code/modules/antagonists/role_preference/_role_preference.dm
+++ b/code/modules/antagonists/role_preference/_role_preference.dm
@@ -1,13 +1,55 @@
/datum/role_preference
var/name
+ /// A brief description of this role, to display in the preferences menu.
+ var/description
/// What heading to display this entry under in the preferences menu. Use ROLE_PREFERENCE_CATEGORY defines.
var/category
- /// The Antagonist datum typepath for this entry, if there is one. Used to get data about the role for display (bans etc)
- var/datum/antagonist/antag_datum
/// The base abstract path for this subtype.
var/abstract_type = /datum/role_preference
+ /// The Antagonist datum typepath for this entry, if there is one. Used to get data about the role for display (bans etc)
+ var/datum/antagonist/antag_datum
/// If this preference can vary between characters.
var/per_character = FALSE
+ /// The typepath for the outfit to show in the preview for the preferences menu.
+ var/preview_outfit
+ /// Role preference path to use the icon of, if we're duplicating another.
+ var/use_icon
+
+/// Creates an icon from the preview outfit.
+/// Custom implementors of `get_preview_icon` should use this, as the
+/// result of `get_preview_icon` is expected to be the completed version.
+/datum/role_preference/proc/render_preview_outfit(datum/outfit/outfit, mob/living/carbon/human/dummy)
+ dummy = dummy || new /mob/living/carbon/human/dummy/consistent
+ dummy.equipOutfit(outfit, visualsOnly = TRUE)
+ COMPILE_OVERLAYS(dummy)
+ var/icon = getFlatIcon(dummy)
+
+ // We don't want to qdel the dummy right away, since its items haven't initialized yet.
+ SSatoms.prepare_deletion(dummy)
+
+ return icon
+
+/// Given an icon, will crop it to be consistent of those in the preferences menu.
+/// Not necessary, and in fact will look bad if it's anything other than a human.
+/datum/role_preference/proc/finish_preview_icon(icon/icon)
+ // Zoom in on the top of the head and the chest
+ // I have no idea how to do this dynamically.
+ icon.Scale(115, 115)
+
+ // This is probably better as a Crop, but I cannot figure it out.
+ icon.Shift(WEST, 8)
+ icon.Shift(SOUTH, 30)
+
+ icon.Crop(1, 1, ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return icon
+
+/// Returns the icon to show on the preferences menu.
+/datum/role_preference/proc/get_preview_icon()
+ if (isnull(preview_outfit))
+ return null
+
+ return finish_preview_icon(render_preview_outfit(preview_outfit))
/// Includes latejoin and roundstart antagonists
/datum/role_preference/antagonist
diff --git a/code/modules/antagonists/role_preference/role_antagonists.dm b/code/modules/antagonists/role_preference/role_antagonists.dm
index b952e5ad233a7..f132e808cc5bb 100644
--- a/code/modules/antagonists/role_preference/role_antagonists.dm
+++ b/code/modules/antagonists/role_preference/role_antagonists.dm
@@ -1,43 +1,387 @@
+#define TRAITOR_DESC "An unpaid debt. A score to be settled. Maybe you were just in the wrong \
+ place at the wrong time. Whatever the reasons, you were selected to \
+ infiltrate Space Station 13."
+#define TRAITOR_DESC_DETAILS "Start with a set of sinister objectives and an uplink to purchase \
+ items to get the job done."
+
+/datum/role_preference/antagonist/traitor
+ name = "Traitor"
+ description = TRAITOR_DESC + "\n" + TRAITOR_DESC_DETAILS
+ antag_datum = /datum/antagonist/traitor
+ preview_outfit = /datum/outfit/traitor
+
+/datum/role_preference/midround_living/traitor
+ name = "Syndicate Sleeper Agent"
+ description = TRAITOR_DESC + "\n" + TRAITOR_DESC_DETAILS
+ antag_datum = /datum/antagonist/traitor
+ use_icon = /datum/role_preference/antagonist/traitor
+
+#undef TRAITOR_DESC
+
+/datum/role_preference/antagonist/internal_affairs
+ name = "Internal Affairs Agent"
+ description = "A traitor who was actually hired by Nanotrasen to stage a Syndicate attack.\n" + TRAITOR_DESC_DETAILS
+ antag_datum = /datum/antagonist/traitor/internal_affairs
+ use_icon = /datum/role_preference/antagonist/traitor
+ category = ROLE_PREFERENCE_CATEGORY_LEGACY
+
+/datum/outfit/traitor
+ name = "Traitor (Preview only)"
+
+ uniform = /obj/item/clothing/under/syndicate
+ gloves = /obj/item/clothing/gloves/combat
+ mask = /obj/item/clothing/mask/gas
+ l_hand = /obj/item/melee/transforming/energy/sword
+ r_hand = /obj/item/gun/energy/kinetic_accelerator/crossbow
+
+/datum/outfit/traitor/post_equip(mob/living/carbon/human/H, visualsOnly)
+ var/obj/item/melee/transforming/energy/sword/sword = locate() in H.held_items
+ sword.icon_state = "swordred"
+ H.update_inv_hands()
+ H.hair_style = "Messy"
+ H.hair_color = "431"
+ H.update_hair()
+
+/datum/role_preference/antagonist/changeling
+ name = "Changeling"
+ description = "A highly intelligent alien predator that is capable of altering their \
+ shape to flawlessly resemble a human.\n\
+ Transform yourself or others into different identities, and buy from an \
+ arsenal of biological weaponry with the DNA you collect."
+ antag_datum = /datum/antagonist/changeling
+
+/datum/role_preference/antagonist/changeling/get_preview_icon()
+ var/icon/final_icon = render_preview_outfit(/datum/outfit/medical_doctor_changeling_preview)
+ var/icon/split_icon = render_preview_outfit(/datum/outfit/job/engineer)
+
+ final_icon.Shift(WEST, world.icon_size / 2)
+ final_icon.Shift(EAST, world.icon_size / 2)
+
+ split_icon.Shift(EAST, world.icon_size / 2)
+ split_icon.Shift(WEST, world.icon_size / 2)
+
+ final_icon.Blend(split_icon, ICON_OVERLAY)
+
+ return finish_preview_icon(final_icon)
+
+/datum/outfit/medical_doctor_changeling_preview
+ name = "Medical Doctor Changeling (Preview only)"
+ uniform = /obj/item/clothing/under/rank/medical/doctor
+ suit = /obj/item/clothing/suit/toggle/labcoat/med
+ gloves = /obj/item/clothing/gloves/color/latex/nitrile
+ r_hand = /obj/item/melee/arm_blade
+
+/datum/outfit/medical_doctor_changeling_preview/post_equip(mob/living/carbon/human/H, visualsOnly)
+ H.dna.features["mcolor"] = "8d8"
+ H.dna.features["horns"] = "Short"
+ H.dna.features["frills"] = "Simple"
+ H.set_species(/datum/species/lizard)
+
/datum/role_preference/antagonist/blood_brother
name = "Blood Brother"
+ description = "Team up with other crew members as blood brothers to combine the strengths \
+ of your departments, break each other out of prison, and overwhelm the station."
antag_datum = /datum/antagonist/brother
+/datum/role_preference/antagonist/blood_brother/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/brother1 = new
+ var/mob/living/carbon/human/dummy/consistent/brother2 = new
+
+ brother1.hair_style = "Pigtails"
+ brother1.hair_color = "532"
+ brother1.update_hair()
+
+ brother2.dna.features["moth_antennae"] = "Plain"
+ brother2.dna.features["moth_markings"] = "None"
+ brother2.dna.features["moth_wings"] = "Plain"
+ brother2.set_species(/datum/species/moth)
+
+ var/icon/brother1_icon = render_preview_outfit(/datum/outfit/job/quartermaster, brother1)
+ brother1_icon.Blend(icon('icons/effects/blood.dmi', "maskblood"), ICON_OVERLAY)
+ brother1_icon.Shift(WEST, 8)
+
+ var/icon/brother2_icon = render_preview_outfit(/datum/outfit/job/scientist, brother2)
+ brother2_icon.Blend(icon('icons/effects/blood.dmi', "uniformblood"), ICON_OVERLAY)
+ brother2_icon.Shift(EAST, 8)
+
+ var/icon/final_icon = brother1_icon
+ final_icon.Blend(brother2_icon, ICON_OVERLAY)
+
+ qdel(brother1)
+ qdel(brother2)
+
+ return finish_preview_icon(final_icon)
+
/datum/role_preference/antagonist/blood_cultist
name = "Blood Cultist"
+ description = "The Geometer of Blood, Nar-Sie, has sent a number of her followers to \
+ Space Station 13. As a cultist, you have an abundance of cult magics at \
+ your disposal, something for all situations. You must work with your \
+ brethren to summon an avatar of your eldritch goddess!\n\
+ Armed with blood magic, convert crew members to the Blood Cult, sacrifice \
+ those who get in the way, and summon Nar-Sie."
antag_datum = /datum/antagonist/cult
+/datum/role_preference/antagonist/blood_cultist/get_preview_icon()
+ var/icon/icon = render_preview_outfit(/datum/outfit/blood_cult_preview)
+
+ // The longsword is 64x64, but getFlatIcon crunches to 32x32.
+ // So I'm just going to add it in post, screw it.
+
+ // Center the dude, because item icon states start from the center.
+ // This makes the image 64x64.
+ icon.Crop(-15, -15, 48, 48)
+
+ var/obj/item/melee/cultblade/longsword = new
+ icon.Blend(icon(longsword.lefthand_file, longsword.item_state), ICON_OVERLAY)
+ qdel(longsword)
+
+ // Move the guy back to the bottom left, 32x32.
+ icon.Crop(17, 17, 48, 48)
+
+ return finish_preview_icon(icon)
+
+/datum/outfit/blood_cult_preview
+ name = "Blood Cultist (Preview only)"
+ uniform = /obj/item/clothing/under/syndicate
+ suit = /obj/item/clothing/suit/hooded/cultrobes/cult_shield/anyone
+ head = /obj/item/clothing/head/hooded/cult_hoodie
+ r_hand = /obj/item/melee/blood_magic/stun
+ l_hand = /obj/item/shield/mirror
+
+/datum/outfit/blood_cult_preview/post_equip(mob/living/carbon/human/H, visualsOnly)
+ H.eye_color = BLOODCULT_EYE
+ H.update_body()
+
/datum/role_preference/antagonist/clock_cultist
name = "Clock Cultist"
+ description = "Hailing from the clockwork city of Reebe, serve your god, Ratvar. \
+ Gather power to summon an avatar of Ratvar through the clockwork rift!\n\
+ Drop down among the station to install cogs into APCs to gain power. Be careful, as when the rift opens, \
+ the crew will rush into Reebe! Build defenses to slow down their entry."
antag_datum = /datum/antagonist/servant_of_ratvar
+ preview_outfit = /datum/outfit/clockcult_preview
+
+/datum/outfit/clockcult_preview
+ name = "Servant of Ratvar (Preview only)"
+ uniform = /obj/item/clothing/under/rank/engineering/engineer
+ belt = /obj/item/storage/belt/utility
+ suit = /obj/item/clothing/suit/clockwork/anyone
+ l_hand = /obj/item/clockwork/weapon/brass_spear
+ head = /obj/item/clothing/head/helmet/clockcult
+ gloves = /obj/item/clothing/gloves/clockcult
/datum/role_preference/antagonist/devil
name = "Devil"
+ description = "Sign deals with crewmembers, turn them to the side of the Devil."
antag_datum = /datum/antagonist/devil
+ preview_outfit = /datum/outfit/devil_preview
+ category = ROLE_PREFERENCE_CATEGORY_LEGACY
+
+/datum/outfit/devil_preview
+ name = "Devil (Preview only)"
+ uniform = /obj/item/clothing/under/rank/civilian/lawyer/black
+ r_hand = /obj/item/storage/briefcase
+
+/datum/outfit/devil_preview/post_equip(mob/living/carbon/human/H, visualsOnly)
+ H.dna.features["mcolor"] = "511"
+ H.dna.features["horns"] = "Simple"
+ H.set_species(/datum/species/lizard)
/datum/role_preference/antagonist/revolutionary
name = "Head Revolutionary"
+ description = "Armed with a flash, convert as many people to the revolution as you can.\n\
+ Kill or exile all heads of staff on the station."
antag_datum = /datum/antagonist/rev/head
+ preview_outfit = /datum/outfit/revolutionary
+ category = ROLE_PREFERENCE_CATEGORY_LEGACY
+
+/datum/outfit/revolutionary
+ name = "Revolutionary (Preview only)"
+ uniform = /obj/item/clothing/under/costume/soviet
+ head = /obj/item/clothing/head/ushanka
+ gloves = /obj/item/clothing/gloves/color/black
+ l_hand = /obj/item/spear
+ r_hand = /obj/item/assembly/flash
+
+/datum/role_preference/antagonist/revolutionary/get_preview_icon()
+ var/icon/final_icon = render_preview_outfit(preview_outfit)
+
+ final_icon.Blend(make_assistant_icon("Business Hair"), ICON_UNDERLAY, -8, 0)
+ final_icon.Blend(make_assistant_icon("CIA"), ICON_UNDERLAY, 8, 0)
+
+ // Apply the rev head HUD, but scale up the preview icon a bit beforehand.
+ // Otherwise, the R gets cut off.
+ final_icon.Scale(64, 64)
+
+ var/icon/rev_head_icon = icon('icons/mob/hud.dmi', "rev_head")
+ rev_head_icon.Scale(48, 48)
+ rev_head_icon.Crop(1, 1, 64, 64)
+ rev_head_icon.Shift(EAST, 10)
+ rev_head_icon.Shift(NORTH, 16)
+ final_icon.Blend(rev_head_icon, ICON_OVERLAY)
+
+ return finish_preview_icon(final_icon)
+
+/datum/role_preference/antagonist/revolutionary/proc/make_assistant_icon(hair_style)
+ var/mob/living/carbon/human/dummy/consistent/assistant = new
+ assistant.hair_style = hair_style
+ assistant.update_hair()
+
+ var/icon/assistant_icon = render_preview_outfit(/datum/outfit/job/assistant/consistent, assistant)
+ assistant_icon.ChangeOpacity(0.5)
+
+ qdel(assistant)
+
+ return assistant_icon
/datum/role_preference/antagonist/heretic
name = "Heretic"
+ description = "Find hidden influences and sacrifice crew members to gain magical \
+ powers and ascend as one of several paths. \n\
+ Forgotten, devoured, gutted. Humanity has forgotten the eldritch forces \
+ of decay, but the mansus veil has weakened. We will make them taste fear \
+ again..."
antag_datum = /datum/antagonist/heretic
+/datum/role_preference/antagonist/heretic/get_preview_icon()
+ var/icon/icon = render_preview_outfit(/datum/outfit/heretic_preview)
+
+ // The sickly blade is 64x64, but getFlatIcon crunches to 32x32.
+ // So I'm just going to add it in post, screw it.
+
+ // Center the dude, because item icon states start from the center.
+ // This makes the image 64x64.
+ icon.Crop(-15, -15, 48, 48)
+
+ var/obj/item/melee/sickly_blade/ash/blade = new
+ icon.Blend(icon(blade.lefthand_file, blade.item_state), ICON_OVERLAY)
+ qdel(blade)
+
+ // Move the guy back to the bottom left, 32x32.
+ icon.Crop(17, 17, 48, 48)
+
+ return finish_preview_icon(icon)
+
+/datum/outfit/heretic_preview
+ name = "Heretic (Preview only)"
+ suit = /obj/item/clothing/suit/hooded/cultrobes/eldritch
+ head = /obj/item/clothing/head/hooded/cult_hoodie/eldritch
+ r_hand = /obj/item/melee/touch_attack/mansus_fist
+
/datum/role_preference/antagonist/hivemind_host
name = "Hivemind Host"
+ description = "A powerful host of a Hivemind. Assimilate crew into your hive to grow your power. \
+ Use the members of your hive as machines in your objectives, and work with or against other Hiveminds on the station."
antag_datum = /datum/antagonist/hivemind
+ category = ROLE_PREFERENCE_CATEGORY_LEGACY
+
+/datum/role_preference/antagonist/hivemind_host/get_preview_icon()
+ var/icon/background = icon('icons/effects/hivemind.dmi', "awoken")
+ var/icon/outfit = render_preview_outfit(/datum/outfit/hivemind_host_preview)
+ background.Blend(outfit, ICON_OVERLAY)
+ return finish_preview_icon(background)
+
+/datum/outfit/hivemind_host_preview
+ name = "Hivemind Host (Preview only)"
+ glasses = /obj/item/clothing/glasses/sunglasses/advanced/reagent
+ uniform = /obj/item/clothing/under/rank/civilian/bartender
+ suit = /obj/item/clothing/suit/armor/vest
+
+/datum/outfit/hivemind_host_preview/post_equip(mob/living/carbon/human/H, visualsOnly = FALSE)
+ H.hair_style = "Bob Hair 4"
+ H.hair_color = "111"
+ H.gradient_style = "Reflected Inverse"
+ H.gradient_color = "808"
+ H.update_hair()
/datum/role_preference/antagonist/incursionist
name = "Incursionist"
+ description = "As a member of the Syndicate Incursion, work with your team of agents to accomplish your objectives.\n\
+ Use your radio to speak with other members of the incursion, and keep security off your tail. \
+ Use your uplink to purchase items, and get the job done."
antag_datum = /datum/antagonist/incursion
-/datum/role_preference/antagonist/excommunicate
- name = "Excommunicated Syndicate Agent"
- antag_datum = /datum/antagonist/traitor/excommunicate
+/datum/role_preference/antagonist/incursionist/get_preview_icon()
+ var/icon/final_icon = render_preview_outfit(/datum/outfit/traitor/incursion)
+ var/icon/dummy_icon = render_preview_outfit(/datum/outfit/traitor)
+ dummy_icon.ChangeOpacity(0.75)
+
+ final_icon.Blend(dummy_icon, ICON_UNDERLAY, -8, 0)
+ final_icon.Blend(dummy_icon, ICON_UNDERLAY, 8, 0)
+
+ // Apply the incursion HUD, but scale up the preview icon a bit beforehand.
+ // Otherwise, the I gets cut off.
+ final_icon.Scale(64, 64)
+
+ var/icon/inc_icon = icon('icons/mob/hud.dmi', "incursion")
+ inc_icon.Scale(48, 48)
+ inc_icon.Crop(1, 1, 64, 64)
+ inc_icon.Shift(EAST, 8)
+ inc_icon.Shift(NORTH, 16)
+ final_icon.Blend(inc_icon, ICON_OVERLAY)
+
+ return finish_preview_icon(final_icon)
+
+/datum/outfit/traitor/incursion
+ name = "Incursionist (Preview only)"
+ uniform = /obj/item/clothing/under/rank/cargo/quartermaster
+ glasses = /obj/item/clothing/glasses/sunglasses/advanced
+ head = /obj/item/clothing/head/ushanka
+ mask = null
/datum/role_preference/antagonist/gangster
name = "Gangster"
+ description = "Convince people to join your gang, wear your uniform, tag turf for the gang, and accomplish your gang's goals."
antag_datum = /datum/antagonist/gang
+ preview_outfit = /datum/outfit/gangster_preview
+ category = ROLE_PREFERENCE_CATEGORY_LEGACY
-/datum/role_preference/antagonist/internal_affairs
- name = "Internal Affairs Agent"
- antag_datum = /datum/antagonist/traitor/internal_affairs
+/datum/outfit/gangster_preview
+ name = "Gangster (Preview only)"
+ head = /obj/item/clothing/head/beanie/black
+ uniform = /obj/item/clothing/under/syndicate/combat
+ suit = /obj/item/clothing/suit/jacket
+
+/datum/role_preference/antagonist/nuclear_operative
+ name = "Nuclear Operative"
+ description = "Congratulations, agent. You have been chosen to join the Syndicate \
+ Nuclear Operative strike team. Your mission, whether or not you choose \
+ to accept it, is to destroy Nanotrasen's most advanced research facility! \
+ That's right, you're going to Space Station 13.\n\
+ Retrieve the nuclear authentication disk, use it to activate the nuclear \
+ fission explosive, and destroy the station."
+ antag_datum = /datum/antagonist/nukeop
+
+/datum/role_preference/antagonist/nuclear_operative/get_preview_icon()
+ var/icon/final_icon = icon('icons/effects/effects.dmi', "nothing")
+ var/icon/foreground = render_preview_outfit(/datum/outfit/nuclear_operative)
+ var/icon/background = icon(foreground)
+ background.Blend(rgb(206, 206, 206, 220), ICON_MULTIPLY)
+
+ final_icon.Blend(background, ICON_OVERLAY, -world.icon_size / 4, 0)
+ final_icon.Blend(background, ICON_OVERLAY, world.icon_size / 4, 0)
+ final_icon.Blend(foreground, ICON_OVERLAY, 0, 0)
+
+ return finish_preview_icon(final_icon)
+
+/datum/outfit/nuclear_operative
+ name = "Nuclear Operative (Preview only)"
+
+ suit = /obj/item/clothing/suit/space/hardsuit/syndi
+ head = /obj/item/clothing/head/helmet/space/hardsuit/syndi
+
+/datum/role_preference/antagonist/wizard
+ name = "Wizard"
+ description = "GREETINGS. WE'RE THE WIZARDS OF THE WIZARD'S FEDERATION.\n\
+ Choose between a variety of powerful spells in order to cause chaos among Space Station 13."
+ antag_datum = /datum/antagonist/wizard
+ preview_outfit = /datum/outfit/wizard
+
+/datum/role_preference/antagonist/excommunicate
+ name = "Excommunicate Agent"
+ description = "A traitor who has been declared an excommunicate of the Syndicate. You're being hunted down by an incursion... watch your back.\n" + TRAITOR_DESC_DETAILS
+ antag_datum = /datum/antagonist/traitor/excommunicate
+ use_icon = /datum/role_preference/antagonist/traitor
+
+#undef TRAITOR_DESC_DETAILS
diff --git a/code/modules/antagonists/role_preference/role_changeling.dm b/code/modules/antagonists/role_preference/role_changeling.dm
deleted file mode 100644
index cf0399b0bcf72..0000000000000
--- a/code/modules/antagonists/role_preference/role_changeling.dm
+++ /dev/null
@@ -1,3 +0,0 @@
-/datum/role_preference/antagonist/changeling
- name = "Changeling"
- antag_datum = /datum/antagonist/changeling
diff --git a/code/modules/antagonists/role_preference/role_midrounds.dm b/code/modules/antagonists/role_preference/role_midrounds.dm
index 2d75b9d747396..874fad0c1093d 100644
--- a/code/modules/antagonists/role_preference/role_midrounds.dm
+++ b/code/modules/antagonists/role_preference/role_midrounds.dm
@@ -1,67 +1,303 @@
/datum/role_preference/midround_ghost/blob
name = "Blob"
+ description = "The blob infests the station and destroys everything in its path, including \
+ hull, fixtures, and creatures.\n\
+ Spread your mass, collect resources, and \
+ consume the entire station. Make sure to prepare your defenses, because the \
+ crew will be alerted to your presence!"
antag_datum = /datum/antagonist/blob
+/datum/role_preference/midround_ghost/blob/get_preview_icon()
+ var/datum/blobstrain/reagent/reactive_spines/reactive_spines = /datum/blobstrain/reagent/reactive_spines
+
+ var/icon/icon = icon('icons/mob/blob.dmi', "blob_core")
+ icon.Blend(initial(reactive_spines.color), ICON_MULTIPLY)
+ icon.Blend(icon('icons/mob/blob.dmi', "blob_core_overlay"), ICON_OVERLAY)
+ icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return icon
+
/datum/role_preference/midround_ghost/xenomorph
name = "Xenomorph"
+ description = "Become the extraterrestrial xenomorph. Start as a larva, and progress \
+ your way up the caste, including even the Queen!"
antag_datum = /datum/antagonist/xeno
+/datum/role_preference/midround_ghost/xenomorph/get_preview_icon()
+ return finish_preview_icon(icon('icons/mob/alien.dmi', "alienh"))
+
/datum/role_preference/midround_ghost/nightmare
name = "Nightmare"
+ description = "Use your light eater to break sources of light to survive and thrive. \
+ Jaunt through the darkness and seek your prey with night vision."
antag_datum = /datum/antagonist/nightmare
+ preview_outfit = /datum/outfit/nightmare
+
+/datum/outfit/nightmare
+ name = "Nightmare (Preview only)"
+
+/datum/outfit/nightmare/post_equip(mob/living/carbon/human/human, visualsOnly)
+ human.set_species(/datum/species/shadow/nightmare)
/datum/role_preference/midround_ghost/space_dragon
name = "Space Dragon"
+ description = "Become a ferocious space dragon. Breathe fire, summon an army of space \
+ carps, crush walls, and terrorize the station."
antag_datum = /datum/antagonist/space_dragon
+/datum/role_preference/midround_ghost/space_dragon/get_preview_icon()
+ var/icon/icon = icon('icons/mob/spacedragon.dmi', "spacedragon")
+
+ icon.Blend("#7848bb", ICON_MULTIPLY)
+ icon.Blend(icon('icons/mob/spacedragon.dmi', "overlay_base"), ICON_OVERLAY)
+
+ icon.Crop(10, 9, 54, 53)
+ icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return icon
+
+/datum/role_preference/midround_ghost/nuclear_operative
+ name = "Nuclear Operative (Midround)"
+ description = "Congratulations, agent. You have been chosen to join the Syndicate \
+ Nuclear Operative strike team. Your mission, whether or not you choose \
+ to accept it, is to destroy Nanotrasen's most advanced research facility! \
+ That's right, you're going to Space Station 13.\n\
+ Retrieve the nuclear authentication disk, use it to activate the nuclear \
+ fission explosive, and destroy the station."
+ antag_datum = /datum/antagonist/nukeop
+ use_icon = /datum/role_preference/antagonist/nuclear_operative
+
+/datum/role_preference/midround_ghost/wizard
+ name = "Wizard (Midround)"
+ description = "GREETINGS. WE'RE THE WIZARDS OF THE WIZARD'S FEDERATION.\n\
+ Choose between a variety of powerful spells in order to cause chaos among Space Station 13."
+ antag_datum = /datum/antagonist/wizard
+ use_icon = /datum/role_preference/antagonist/wizard
+
/datum/role_preference/midround_ghost/abductor
name = "Abductor"
+ description = "Abductors are technologically advanced alien society set on cataloging \
+ all species in the system. Unfortunately for their subjects their methods \
+ are quite invasive. \n\
+ You and a partner will become the abductor scientist and agent duo. \
+ As an agent, abduct unassuming victims and bring them back to your UFO. \
+ As a scientist, scout out victims for your agent, keep them safe, and \
+ operate on whoever they bring back."
antag_datum = /datum/antagonist/abductor
+/datum/role_preference/midround_ghost/abductor/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/scientist = new
+ var/mob/living/carbon/human/dummy/consistent/agent = new
+
+ scientist.set_species(/datum/species/abductor)
+ agent.set_species(/datum/species/abductor)
+
+ var/icon/scientist_icon = render_preview_outfit(/datum/outfit/abductor/scientist, scientist)
+ scientist_icon.Shift(WEST, 8)
+
+ var/icon/agent_icon = render_preview_outfit(/datum/outfit/abductor/agent, agent)
+ agent_icon.Shift(EAST, 8)
+
+ var/icon/final_icon = scientist_icon
+ final_icon.Blend(agent_icon, ICON_OVERLAY)
+
+ qdel(scientist)
+ qdel(agent)
+
+ return finish_preview_icon(final_icon)
+
/datum/role_preference/midround_ghost/space_pirate
name = "Space Pirate"
+ description = "Gather your crewmates and infiltrate Space Station 13's vault. \
+ Loot that booty, and don't get gunned down in the process!"
antag_datum = /datum/antagonist/pirate
+/datum/role_preference/midround_ghost/space_pirate/get_preview_icon()
+ var/icon/final_icon = icon('icons/effects/effects.dmi', "nothing")
+ var/icon/foreground = render_preview_outfit(/datum/outfit/pirate_space_preview/captain)
+ var/icon/background = render_preview_outfit(/datum/outfit/pirate_space_preview)
+ background.Blend(rgb(206, 206, 206, 220), ICON_MULTIPLY)
+
+ final_icon.Blend(background, ICON_OVERLAY, -world.icon_size / 4, 0)
+ final_icon.Blend(background, ICON_OVERLAY, world.icon_size / 4, 0)
+ final_icon.Blend(foreground, ICON_OVERLAY, 0, 0)
+
+ return finish_preview_icon(final_icon)
+
+/datum/outfit/pirate_space_preview
+ name = "Space Pirate (Preview only)"
+ uniform = /obj/item/clothing/under/costume/pirate
+ suit = /obj/item/clothing/suit/space/pirate
+ head = /obj/item/clothing/head/helmet/space/pirate/bandana
+ glasses = /obj/item/clothing/glasses/eyepatch
+
+/datum/outfit/pirate_space_preview/post_equip(mob/living/carbon/human/H, visualsOnly)
+ H.set_species(/datum/species/skeleton)
+
+/datum/outfit/pirate_space_preview/captain
+ name = "Space Pirate Captain (Preview only)"
+ head = /obj/item/clothing/head/helmet/space/pirate
+
/datum/role_preference/midround_ghost/revenant
name = "Revenant"
+ description = "Become the mysterious revenant. Break windows, overload lights, and eat \
+ the crew's life force, all while talking to your old community of disgruntled ghosts."
antag_datum = /datum/antagonist/revenant
+/datum/role_preference/midround_ghost/revenant/get_preview_icon()
+ return finish_preview_icon(icon('icons/mob/mob.dmi', "revenant_revealed"))
+
/datum/role_preference/midround_ghost/spider
name = "Spider"
+ description = "Swarm and spread your webs accross every corner of the station. \
+ Work with your cluster of fellow spiders, each with different roles - melee, venom, webbing, and egg-laying."
antag_datum = /datum/antagonist/spider
+/datum/role_preference/midround_ghost/spider/get_preview_icon()
+ return finish_preview_icon(icon('icons/mob/animal.dmi', "broodmother"))
+
/datum/role_preference/midround_ghost/swarmer
name = "Swarmer"
- antag_datum = /datum/antagonist/swarmer
+ description = "A swarmer is a small robot that replicates itself autonomously with \
+ nearby given materials and prepare structures that they come \
+ across for the following invasion force. \n\
+ Consume machines, structures, walls, anything to get materials. Replicate \
+ as many swarmers as you can to repeat the process."
+
+/datum/role_preference/midround_ghost/swarmer/get_preview_icon()
+ var/icon/swarmer_icon = icon('icons/mob/swarmer.dmi', "swarmer")
+ swarmer_icon.Shift(NORTH, 8)
+ return finish_preview_icon(swarmer_icon)
/datum/role_preference/midround_ghost/morph
name = "Morph"
+ description = "Eat everything in your sights, confuse the crew with your shapeshifting abilities and hallucination toxin, \
+ and chow down on dead things to heal."
antag_datum = /datum/antagonist/morph
+/datum/role_preference/midround_ghost/morph/get_preview_icon()
+ var/icon/morph_icon = icon('icons/mob/animal.dmi', "morph")
+ morph_icon.Shift(NORTH, 8)
+ return finish_preview_icon(morph_icon)
+
/datum/role_preference/midround_ghost/fugitive
name = "Fugitive"
+ description = "You're a fugitive, escaped from imprisonment. You've managed to make it to Space Station 13. \
+ Now is the time to run and hide. But be careful, the Fugitive Hunters are hot on your tail."
antag_datum = /datum/antagonist/fugitive
+ preview_outfit = /datum/outfit/waldo
/datum/role_preference/midround_ghost/fugitive_hunter
name = "Fugitive Hunter"
+ description = "You've been hired to hunt down the Fugitives who have escaped aboard Space Station 13. \
+ Find them, and bring them to the bluespace capture console aboard your shuttle. Cooperate with the station crew if necessary."
antag_datum = /datum/antagonist/fugitive_hunter
+/datum/role_preference/midround_ghost/fugitive_hunter/get_preview_icon()
+ var/icon/final_icon = icon('icons/effects/effects.dmi', "nothing")
+ var/icon/foreground = render_preview_outfit(/datum/outfit/bounty/hook)
+ var/icon/background = render_preview_outfit(/datum/outfit/russian_hunter/leader)
+ var/icon/background_2 = render_preview_outfit(/datum/outfit/spacepol/sergeant)
+ background.Blend(rgb(206, 206, 206, 220), ICON_MULTIPLY)
+ background_2.Blend(rgb(206, 206, 206, 220), ICON_MULTIPLY)
+
+ final_icon.Blend(background, ICON_OVERLAY, -world.icon_size / 4, 0)
+ final_icon.Blend(background_2, ICON_OVERLAY, world.icon_size / 4, 0)
+ final_icon.Blend(foreground, ICON_OVERLAY, 0, 0)
+
+ return finish_preview_icon(final_icon)
+
/datum/role_preference/midround_ghost/slaughter_demon
name = "Slaughter Demon"
+ description = "Use your blood jaunt to terrorize the crew, and drag them all to hell."
antag_datum = /datum/antagonist/slaughter
+ category = ROLE_PREFERENCE_CATEGORY_LEGACY
+
+/datum/role_preference/midround_ghost/slaughter_demon/get_preview_icon()
+ return finish_preview_icon(icon('icons/mob/mob.dmi', "daemon"))
/datum/role_preference/midround_ghost/devil
name = "Devil (Midround)"
+ description = "Sign deals with crewmembers, turn them to the side of the Devil."
antag_datum = /datum/antagonist/devil
+ use_icon = /datum/role_preference/antagonist/devil
+ category = ROLE_PREFERENCE_CATEGORY_LEGACY
/datum/role_preference/midround_ghost/ninja
name = "Ninja"
+ description = "Become a conniving space ninja, equipped with a teleporting katana, gloves to hack \
+ into airlocks and APCs, a suit to make you go near-invisible, \
+ as well as a variety of abilities in your kit. Capture beings in your net and get on your way!"
antag_datum = /datum/antagonist/ninja
+ preview_outfit = /datum/outfit/ninja_preview
+
+/datum/outfit/ninja_preview
+ name = "Space Ninja (Preview only)"
+ uniform = /obj/item/clothing/under/color/black
+ suit = /obj/item/clothing/suit/space/space_ninja
+ glasses = /obj/item/clothing/glasses/night
+ mask = /obj/item/clothing/mask/gas/space_ninja
+ head = /obj/item/clothing/head/helmet/space/space_ninja
+ gloves = /obj/item/clothing/gloves/space_ninja
+ back = /obj/item/tank/jetpack/carbondioxide
+ // No katana because it has trouble GCing
+ //belt = /obj/item/energy_katana
/datum/role_preference/midround_living/malfunctioning_ai
name = "Malfunctioning AI"
+ description = "With a law zero to complete your objectives at all costs, combine your \
+ omnipotence and malfunction modules to wreak havoc across the station. \
+ Go delta to destroy the station and all those who opposed you."
+ // Yes, it's under traitor.
antag_datum = /datum/antagonist/traitor
+/datum/role_preference/midround_living/malfunctioning_ai/get_preview_icon()
+ var/icon/malf_ai_icon = icon('icons/mob/ai.dmi', "ai-red")
+
+ // Crop out the borders of the AI, just the face
+ malf_ai_icon.Crop(5, 27, 28, 6)
+
+ malf_ai_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return malf_ai_icon
+
/datum/role_preference/midround_living/obsessed
name = "Obsessed"
+ description = "You're obsessed with someone! Your obsession may begin to notice their \
+ personal items are stolen and their coworkers have gone missing, \
+ but will they realize they are your next victim in time?"
antag_datum = /datum/antagonist/obsessed
+
+/datum/role_preference/midround_living/obsessed/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/victim_dummy = new
+ victim_dummy.hair_color = "b96" // Brown
+ victim_dummy.hair_style = "Messy"
+ victim_dummy.update_hair()
+
+ var/icon/obsessed_icon = render_preview_outfit(/datum/outfit/obsessed)
+ obsessed_icon.Blend(icon('icons/effects/blood.dmi', "uniformblood"), ICON_OVERLAY)
+
+ var/icon/final_icon = finish_preview_icon(obsessed_icon)
+
+ final_icon.Blend(
+ icon('icons/ui_icons/antags/obsessed.dmi', "obsession"),
+ ICON_OVERLAY,
+ ANTAGONIST_PREVIEW_ICON_SIZE - 30,
+ 20,
+ )
+
+ return final_icon
+
+/datum/outfit/obsessed
+ name = "Obsessed (Preview only)"
+
+ uniform = /obj/item/clothing/under/misc/overalls
+ gloves = /obj/item/clothing/gloves/color/latex
+ mask = /obj/item/clothing/mask/surgical
+ neck = /obj/item/camera
+ suit = /obj/item/clothing/suit/apron
+
+/datum/outfit/obsessed/post_equip(mob/living/carbon/human/H)
+ for(var/obj/item/carried_item in H.get_equipped_items(TRUE))
+ carried_item.add_mob_blood(H)//Oh yes, there will be blood...
+ H.regenerate_icons()
diff --git a/code/modules/antagonists/role_preference/role_operative.dm b/code/modules/antagonists/role_preference/role_operative.dm
deleted file mode 100644
index 6a4fe7a5b9ec6..0000000000000
--- a/code/modules/antagonists/role_preference/role_operative.dm
+++ /dev/null
@@ -1,7 +0,0 @@
-/datum/role_preference/antagonist/nuclear_operative
- name = "Nuclear Operative"
- antag_datum = /datum/antagonist/nukeop
-
-/datum/role_preference/midround_ghost/nuclear_operative
- name = "Nuclear Operative (Midround)"
- antag_datum = /datum/antagonist/nukeop
diff --git a/code/modules/antagonists/role_preference/role_traitor.dm b/code/modules/antagonists/role_preference/role_traitor.dm
deleted file mode 100644
index 46d11945a14c2..0000000000000
--- a/code/modules/antagonists/role_preference/role_traitor.dm
+++ /dev/null
@@ -1,7 +0,0 @@
-/datum/role_preference/antagonist/traitor
- name = "Traitor"
- antag_datum = /datum/antagonist/traitor
-
-/datum/role_preference/midround_living/traitor
- name = "Traitor (Sleeper Agent)"
- antag_datum = /datum/antagonist/traitor
diff --git a/code/modules/antagonists/role_preference/role_wizard.dm b/code/modules/antagonists/role_preference/role_wizard.dm
deleted file mode 100644
index 31681493a5870..0000000000000
--- a/code/modules/antagonists/role_preference/role_wizard.dm
+++ /dev/null
@@ -1,7 +0,0 @@
-/datum/role_preference/antagonist/wizard
- name = "Wizard"
- antag_datum = /datum/antagonist/wizard
-
-/datum/role_preference/midround_ghost/wizard
- name = "Wizard (Midround)"
- antag_datum = /datum/antagonist/wizard
diff --git a/code/modules/antagonists/slaughter/slaughter.dm b/code/modules/antagonists/slaughter/slaughter.dm
index 13a1d60c98416..fc7269fbf6afb 100644
--- a/code/modules/antagonists/slaughter/slaughter.dm
+++ b/code/modules/antagonists/slaughter/slaughter.dm
@@ -97,12 +97,12 @@
user.temporarilyRemoveItemFromInventory(src, TRUE)
src.Insert(user) //Consuming the heart literally replaces your heart with a demon heart. H A R D C O R E
-/obj/item/organ/heart/demon/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/demon/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
if(M.mind)
M.mind.AddSpell(new /obj/effect/proc_holder/spell/bloodcrawl(null))
-/obj/item/organ/heart/demon/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/demon/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
if(M.mind)
M.mind.RemoveSpell(/obj/effect/proc_holder/spell/bloodcrawl)
diff --git a/code/modules/antagonists/xeno/xeno.dm b/code/modules/antagonists/xeno/xeno.dm
index d200fc3d4b981..e482ad0c333d1 100644
--- a/code/modules/antagonists/xeno/xeno.dm
+++ b/code/modules/antagonists/xeno/xeno.dm
@@ -52,7 +52,6 @@
if(owner.antag_hud_icon_state == "xenomorph")
set_antag_hud(owner.current, null)
-
//XENO
/mob/living/carbon/alien/mind_initialize()
..()
diff --git a/code/modules/asset_cache/asset_list.dm b/code/modules/asset_cache/asset_list.dm
index ddd0acfc54737..4e32beb2e0a3d 100644
--- a/code/modules/asset_cache/asset_list.dm
+++ b/code/modules/asset_cache/asset_list.dm
@@ -25,6 +25,9 @@ GLOBAL_LIST_EMPTY(asset_datums)
/// config can, of course, be disabled.
var/cross_round_cachable = FALSE
+ /// Whether or not this asset should be loaded in the "early assets" SS
+ var/early = FALSE
+
/datum/asset/New()
GLOB.asset_datums[type] = src
register()
@@ -508,5 +511,31 @@ GLOBAL_LIST_EMPTY(asset_datums)
/datum/asset/simple/namespaced/proc/get_htmlloader(filename)
return url2htmlloader(SSassets.transport.get_asset_url(filename, assets[filename]))
+/// A subtype to generate a JSON file from a list
+/datum/asset/json
+ _abstract = /datum/asset/json
+ /// The filename, will be suffixed with ".json"
+ var/name
+
+/datum/asset/json/send(client)
+ return SSassets.transport.send_assets(client, "[name].json")
+
+/datum/asset/json/get_url_mappings()
+ return list(
+ "[name].json" = SSassets.transport.get_asset_url("[name].json"),
+ )
+
+/datum/asset/json/register()
+ var/filename = "data/[name].json"
+ fdel(filename)
+ text2file(json_encode(generate()), filename)
+ SSassets.transport.register_asset("[name].json", fcopy_rsc(filename))
+ fdel(filename)
+
+/// Returns the data that will be JSON encoded
+/datum/asset/json/proc/generate()
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("generate() not implemented for [type]!")
+
#undef ASSET_CROSS_ROUND_CACHE_DIRECTORY
diff --git a/code/modules/asset_cache/asset_list_items.dm b/code/modules/asset_cache/asset_list_items.dm
index 7b5743003f29b..2e2ad9b80a0e5 100644
--- a/code/modules/asset_cache/asset_list_items.dm
+++ b/code/modules/asset_cache/asset_list_items.dm
@@ -335,7 +335,7 @@
stack_trace("design [D] with icon '[icon_file]' missing state '[icon_state]'")
continue
#endif
- I = icon(icon_file, icon_state, SOUTH)
+ I = icon(icon_file, icon_state, SOUTH, 1)
else
// construct the icon and slap it into the resource cache
@@ -371,7 +371,7 @@
stack_trace("design [D] with icon '[icon_file]' missing state '[icon_state]'")
continue
#endif
- I = icon(icon_file, icon_state, SOUTH)
+ I = icon(icon_file, icon_state, SOUTH, 1)
// computers (and snowflakes) get their screen and keyboard sprites
if (ispath(item, /obj/machinery/computer) || ispath(item, /obj/machinery/power/solar_control))
@@ -380,9 +380,9 @@
var/keyboard = initial(C.icon_keyboard)
var/all_states = icon_states(icon_file)
if (screen && (screen in all_states))
- I.Blend(icon(icon_file, screen, SOUTH), ICON_OVERLAY)
+ I.Blend(icon(icon_file, screen, SOUTH, 1), ICON_OVERLAY)
if (keyboard && (keyboard in all_states))
- I.Blend(icon(icon_file, keyboard, SOUTH), ICON_OVERLAY)
+ I.Blend(icon(icon_file, keyboard, SOUTH, 1), ICON_OVERLAY)
Insert(initial(D.id), I)
@@ -409,38 +409,41 @@
// building icons for each item
for (var/k in target_items)
var/atom/item = k
- if (!ispath(item, /atom))
+ var/icon/I = get_display_icon_for(item)
+ if(!I)
continue
+ var/imgid = replacetext(replacetext("[item]", "/obj/item/", ""), "/", "-")
+ Insert(imgid, I)
- var/icon_file
- if (initial(item.greyscale_colors) && initial(item.greyscale_config))
- icon_file = SSgreyscale.GetColoredIconByType(initial(item.greyscale_config), initial(item.greyscale_colors))
- else
- icon_file = initial(item.icon)
- var/icon_state = initial(item.icon_state)
-
- #ifdef UNIT_TESTS
- var/icon_states_list = icon_states(icon_file)
- if (!(icon_state in icon_states_list))
- var/icon_states_string
- for (var/an_icon_state in icon_states_list)
- if (!icon_states_string)
- icon_states_string = "[json_encode(an_icon_state)](\ref[an_icon_state])"
- else
- icon_states_string += ", [json_encode(an_icon_state)](\ref[an_icon_state])"
-
- stack_trace("[item] does not have a valid icon state, icon=[icon_file], icon_state=[json_encode(icon_state)](\ref[icon_state]), icon_states=[icon_states_string]")
- continue
- #endif
-
- var/icon/I = icon(icon_file, icon_state, SOUTH, 1)
- var/c = initial(item.color)
- if (!isnull(c) && c != "#FFFFFF")
- I.Blend(c, ICON_MULTIPLY)
+/proc/get_display_icon_for(atom/item)
+ if (!ispath(item, /atom))
+ return FALSE
+ var/icon_file
+ if (initial(item.greyscale_colors) && initial(item.greyscale_config))
+ icon_file = SSgreyscale.GetColoredIconByType(initial(item.greyscale_config), initial(item.greyscale_colors))
+ else
+ icon_file = initial(item.icon)
+ var/icon_state = initial(item.icon_state)
+
+ #ifdef UNIT_TESTS
+ var/icon_states_list = icon_states(icon_file)
+ if (!(icon_state in icon_states_list))
+ var/icon_states_string
+ for (var/an_icon_state in icon_states_list)
+ if (!icon_states_string)
+ icon_states_string = "[json_encode(an_icon_state)](\ref[an_icon_state])"
+ else
+ icon_states_string += ", [json_encode(an_icon_state)](\ref[an_icon_state])"
- var/imgid = replacetext(replacetext("[item]", "/obj/item/", ""), "/", "-")
+ stack_trace("[item] does not have a valid icon state, icon=[icon_file], icon_state=[json_encode(icon_state)](\ref[icon_state]), icon_states=[icon_states_string]")
+ return FALSE
+ #endif
- Insert(imgid, I)
+ var/icon/I = icon(icon_file, icon_state, SOUTH, 1)
+ var/c = initial(item.color)
+ if (!isnull(c) && c != "#FFFFFF")
+ I.Blend(c, ICON_MULTIPLY)
+ return I
/datum/asset/spritesheet/crafting
name = "crafting"
@@ -567,10 +570,9 @@
assets = list()
/datum/asset/simple/portraits/New()
- if(!SSpersistence.paintings || !SSpersistence.paintings[tab] || !length(SSpersistence.paintings[tab]))
+ if(!length(SSpersistence.paintings[tab]))
return
- for(var/p in SSpersistence.paintings[tab])
- var/list/portrait = p
+ for(var/list/portrait as anything in SSpersistence.paintings[tab])
var/png = "data/paintings/[tab]/[portrait["md5"]].png"
if(fexists(png))
var/asset_name = "[tab]_[portrait["md5"]]"
diff --git a/code/modules/asset_cache/transports/asset_transport.dm b/code/modules/asset_cache/transports/asset_transport.dm
index e2787bbd168b9..66f80396ab407 100644
--- a/code/modules/asset_cache/transports/asset_transport.dm
+++ b/code/modules/asset_cache/transports/asset_transport.dm
@@ -144,7 +144,7 @@
/// Precache files without clogging up the browse() queue, used for passively sending files on connection start.
-/datum/asset_transport/proc/send_assets_slow(client/client, list/files, filerate = 3)
+/datum/asset_transport/proc/send_assets_slow(client/client, list/files, filerate = 6)
var/startingfilerate = filerate
for (var/file in files)
if (!client)
diff --git a/code/modules/awaymissions/capture_the_flag.dm b/code/modules/awaymissions/capture_the_flag.dm
index e0a37fb3ee8a6..e602f3b17804b 100644
--- a/code/modules/awaymissions/capture_the_flag.dm
+++ b/code/modules/awaymissions/capture_the_flag.dm
@@ -284,7 +284,7 @@
/obj/machinery/capture_the_flag/proc/spawn_team_member(client/new_team_member)
var/mob/living/carbon/human/M = new/mob/living/carbon/human(get_turf(src))
- new_team_member.prefs.active_character.copy_to(M)
+ new_team_member.prefs.apply_prefs_to(M)
if(!(M.dna.species.type in allowed_species))
M.set_species(/datum/species/human) //default to human if not whitelisted
M.key = new_team_member.key
diff --git a/code/modules/awaymissions/super_secret_room.dm b/code/modules/awaymissions/super_secret_room.dm
index ee37438848ead..81f6a5f036d98 100644
--- a/code/modules/awaymissions/super_secret_room.dm
+++ b/code/modules/awaymissions/super_secret_room.dm
@@ -126,7 +126,7 @@
/obj/item/rupee/Initialize(mapload)
. = ..()
- var/newcolor = color2hex(pick(10;"green", 5;"blue", 3;"red", 1;"purple"))
+ var/newcolor = pick(10;COLOR_GREEN, 5;COLOR_BLUE, 3;COLOR_RED, 1;COLOR_PURPLE)
add_atom_colour(newcolor, FIXED_COLOUR_PRIORITY)
var/static/list/loc_connections = list(
COMSIG_ATOM_ENTERED = PROC_REF(on_entered),
diff --git a/code/modules/client/client_defines.dm b/code/modules/client/client_defines.dm
index f02bafb04ee13..7ade6f31f3fe4 100644
--- a/code/modules/client/client_defines.dm
+++ b/code/modules/client/client_defines.dm
@@ -121,3 +121,6 @@
/// If the client is currently under the restrictions of the interview system
var/interviewee = FALSE
+
+ /// Whether or not this client has standard hotkeys enabled
+ var/hotkeys = TRUE
diff --git a/code/modules/client/client_procs.dm b/code/modules/client/client_procs.dm
index 1e834c1a7edbd..8fcdc104c5355 100644
--- a/code/modules/client/client_procs.dm
+++ b/code/modules/client/client_procs.dm
@@ -122,13 +122,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
hsrc = mentor_datum
if("usr")
hsrc = mob
- if("prefs")
- if (inprefs)
- return
- inprefs = TRUE
- . = prefs.process_link(usr,href_list)
- inprefs = FALSE
- return
if("vars")
return view_var_Topic(href,href_list,hsrc)
@@ -142,11 +135,10 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
..() //redirect to hsrc.Topic()
+/// If this client is BYOND member.
/client/proc/is_content_unlocked()
- if(!prefs.unlock_content)
- to_chat(src, "Become a BYOND member to access member-perks and features, as well as support the engine that makes this game possible. Only 10 bucks for 3 months! Click Here to find out more.")
- return 0
- return 1
+ return prefs.unlock_content
+
/*
* Call back proc that should be checked in all paths where a client can send messages
*
@@ -256,12 +248,12 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
prefs = GLOB.preferences_datums[ckey]
if(prefs)
prefs.parent = src
+ prefs.apply_all_client_preferences()
else
prefs = new /datum/preferences(src)
GLOB.preferences_datums[ckey] = prefs
prefs.last_ip = address //these are gonna be used for banning
prefs.last_id = computer_id //these are gonna be used for banning
- fps = prefs.clientfps
prefs.handle_donator_items()
@@ -414,7 +406,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
add_admin_verbs()
to_chat(src, get_message_output("memo"))
adminGreet()
-
add_verbs_from_config()
var/cached_player_age = set_client_age_from_db(tdata) //we have to cache this because other shit may change it and we need it's current value now down below.
@@ -467,8 +458,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
if(!winexists(src, "asset_cache_browser")) // The client is using a custom skin, tell them.
to_chat(src, "Unable to access asset cache browser, if you are using a custom skin file, please allow DS to download the updated version, if you are not, then make a bug report. This is not a critical issue but can cause issues with resource downloading, as it is impossible to know when extra resources arrived to you.")
- update_ambience_pref()
-
//This is down here because of the browse() calls in tooltip/New()
if(!tooltips)
tooltips = new /datum/tooltip(src)
@@ -955,11 +944,13 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
to_chat(src, "Your previous click was ignored because you've done too many in a second")
return
- if (prefs.toggles2 & PREFTOGGLE_2_HOTKEYS)
+ if (hotkeys)
// If hotkey mode is enabled, then clicking the map will automatically
// unfocus the text bar. This removes the red color from the text bar
// so that the visual focus indicator matches reality.
winset(src, null, "input.background-color=[COLOR_INPUT_DISABLED]")
+ else
+ winset(src, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED]")
..()
@@ -1042,7 +1033,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
if (isliving(mob))
var/mob/living/M = mob
M.update_damage_hud()
- if (prefs.toggles2 & PREFTOGGLE_2_AUTO_FIT_VIEWPORT)
+ if (prefs.read_player_preference(/datum/preference/toggle/auto_fit_viewport))
addtimer(CALLBACK(src,.verb/fit_viewport,10)) //Delayed to avoid wingets from Login calls.
/client/proc/generate_clickcatcher()
@@ -1056,7 +1047,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
void.UpdateGreed(actualview[1],actualview[2])
/client/proc/AnnouncePR(announcement)
- if(prefs && prefs.chat_toggles & CHAT_PULLR)
+ if(prefs && prefs.read_player_preference(/datum/preference/toggle/chat_pullr))
to_chat(src, announcement)
/client/proc/show_character_previews(mutable_appearance/source)
@@ -1156,12 +1147,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
holder.particool = new /datum/particle_editor(in_atom)
holder.particool.ui_interact(mob)
-/client/proc/update_ambience_pref()
- if(prefs.toggles & PREFTOGGLE_SOUND_AMBIENCE)
- SSambience.add_ambience_client(src)
- else
- SSambience.remove_ambience_client(src)
-
/client/proc/give_award(achievement_type, mob/user)
return player_details.achievements.unlock(achievement_type, user)
diff --git a/code/modules/client/loadout/loadout.dm b/code/modules/client/loadout/loadout.dm
index e49fd1374fa34..7fbf25d69755a 100644
--- a/code/modules/client/loadout/loadout.dm
+++ b/code/modules/client/loadout/loadout.dm
@@ -61,6 +61,10 @@ GLOBAL_LIST_EMPTY(gear_datums)
var/skirt_display_name
var/skirt_path = null
var/skirt_description
+ /// If this gear is actually granting an item, and can be equipped.
+ var/is_equippable = TRUE
+ /// If this gear can be purchased again - used for non-items
+ var/multi_purchase = FALSE
/datum/gear/New()
..()
@@ -73,6 +77,7 @@ GLOBAL_LIST_EMPTY(gear_datums)
skirt_description = initial(O.desc)
/datum/gear/proc/purchase(var/client/C) //Called when the gear is first purchased
+ SHOULD_NOT_SLEEP(TRUE)
return
/datum/gear_data
diff --git a/code/modules/client/loadout/loadout_ooc.dm b/code/modules/client/loadout/loadout_ooc.dm
index 475fc188cfa52..5d0557a93df4c 100644
--- a/code/modules/client/loadout/loadout_ooc.dm
+++ b/code/modules/client/loadout/loadout_ooc.dm
@@ -2,21 +2,26 @@
subtype_path = /datum/gear/ooc
sort_category = "OOC"
cost = 10000
+ is_equippable = FALSE
/datum/gear/ooc/char_slot
display_name = "extra character slot"
description = "An extra charslot. Pretty self-explanatory."
cost = 10000
+ path = /obj/item/toy/figure/captain
/datum/gear/ooc/char_slot/purchase(var/client/C)
- C?.prefs?.set_max_character_slots(C.prefs.max_usable_slots + 1)
+ // This is only locally immediately after purchase - this will be incremented on load in preferences.dm
+ C.prefs.max_save_slots += 1
/datum/gear/ooc/real_antagtoken
display_name = "antag token"
description = "If you can afford it, you deserve it."
cost = 100000
+ path = /obj/item/coin/antagtoken
+ multi_purchase = TRUE
/datum/gear/ooc/real_antagtoken/purchase(var/client/C)
- C.inc_antag_token_count(1)
+ INVOKE_ASYNC(C, TYPE_PROC_REF(/client, inc_antag_token_count), 1)
message_admins("[C.ckey] has purchased a genuine antag token.")
log_game("[C.ckey] has purchased a genuine antag token.")
diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm
deleted file mode 100644
index 6ca86f5c1c09d..0000000000000
--- a/code/modules/client/preferences.dm
+++ /dev/null
@@ -1,2250 +0,0 @@
-GLOBAL_LIST_EMPTY(preferences_datums)
-
-/datum/preferences
- var/client/parent
-
- var/default_slot = 1 //Holder so it doesn't default to slot 1, rather the last one used
- // TREAT THIS VAR AS PRIVATE. USE set_max_character_slots() PLEASE
- var/max_usable_slots = 3
-
- //non-preference stuff
- var/muted = 0
- var/last_ip
- var/last_id
-
- /// List of all character saves, with list index being slot ID
- var/list/datum/character_save/character_saves = list()
- /// Active character, ref to an item in that list
- var/datum/character_save/active_character
-
- //game-preferences
- var/lastchangelog = "" //Saved changlog filesize to detect if there was a change
- var/ooccolor = "#c43b23"
- var/asaycolor = "#ff4500" //This won't change the color for current admins, only incoming ones.
- var/tip_delay = 500 //tip delay in milliseconds
-
- //Antag preferences
- var/list/role_preferences = list() //Special role selection
-
- var/UI_style = null
- var/outline_color = COLOR_BLUE_GRAY
-
- ///Whether we want balloon alerts displayed alone, with chat or not displayed at all
- var/see_balloon_alerts = BALLOON_ALERT_ALWAYS
-
- var/toggles = TOGGLES_DEFAULT
- var/toggles2 = TOGGLES_2_DEFAULT
- var/db_flags
- var/chat_toggles = TOGGLES_DEFAULT_CHAT
- var/ghost_form = "ghost"
- var/ghost_orbit = GHOST_ORBIT_CIRCLE
- var/ghost_accs = GHOST_ACCS_DEFAULT_OPTION
- var/ghost_others = GHOST_OTHERS_DEFAULT_OPTION
- var/preferred_map = null
- var/pda_theme = THEME_NTOS
- var/pda_color = "#808000"
-
- // Custom Keybindings
- var/list/key_bindings = null
-
- // 0 = character settings, 1 = game preferences
- var/current_tab = 0
-
- var/unlock_content = 0
-
- var/list/ignoring = list()
-
- var/clientfps = 40
- var/updated_fps = 0
-
- var/parallax
-
- ///What size should pixels be displayed as? 0 is strech to fit
- var/pixel_size = 0
- ///What scaling method should we use?
- var/scaling_method = "normal"
-
- var/list/exp = list()
- var/job_exempt = 0
-
- //Loadout stuff
- var/list/purchased_gear = list()
- var/gear_tab = "General"
-
- var/action_buttons_screen_locs = list()
-
- var/pai_name = ""
- var/pai_description = ""
- var/pai_comment = ""
-
-/datum/preferences/proc/set_max_character_slots(newmax)
- max_usable_slots = min(TRUE_MAX_SAVE_SLOTS, newmax) // Make sure they dont go over
- check_usable_slots()
-
-/datum/preferences/New(client/C)
- parent = C
-
- character_saves.len = TRUE_MAX_SAVE_SLOTS
- for(var/i in 1 to TRUE_MAX_SAVE_SLOTS)
- var/datum/character_save/CS = new()
- CS.slot_number = i
- character_saves[i] = CS
-
- UI_style = GLOB.available_ui_styles[1]
- if(istype(C))
- if(!IS_GUEST_KEY(C.key))
- unlock_content = C.IsByondMember()
- if(unlock_content)
- set_max_character_slots(8)
- else if(!length(key_bindings)) // Guests need default keybinds
- key_bindings = deep_copy_list(GLOB.keybinding_list_by_key)
- var/loaded_preferences_successfully = load_from_database()
- if(loaded_preferences_successfully)
- if("6030fe461e610e2be3a2c3e75c06067e" in purchased_gear) //MD5 hash of, "extra character slot"
- set_max_character_slots(max_usable_slots + 1)
- if(load_characters()) // inside this proc is a disgusting SQL query
- var/datum/character_save/target_save = character_saves[default_slot]
- if(target_save && !target_save.slot_locked)
- active_character = target_save
- else
- active_character = character_saves[1] // Default to first if unavailable
- return
-
- //we couldn't load character data so just randomize the character appearance + name
- active_character = character_saves[1]
- var/fallback_default_species = CONFIG_GET(string/fallback_default_species)
- if(!active_character.pref_species && fallback_default_species != "random")
- var/datum/species/spath = GLOB.species_list[fallback_default_species || "human"]
- active_character.pref_species = new spath
- active_character.randomise() //let's create a random character then - rather than a fat, bald and naked man.
- active_character.real_name = active_character.pref_species.random_name(active_character.gender, TRUE)
- if(!loaded_preferences_successfully)
- save_preferences()
- active_character.save(C) //let's save this new random character so it doesn't keep generating new ones.
- return
-
-#define APPEARANCE_CATEGORY_COLUMN ""
-#define MAX_MUTANT_ROWS 4
-
-/datum/preferences/proc/ShowChoices(mob/user)
- if(!user || !user.client)
- return
- active_character.update_preview_icon(user.client)
- var/list/dat = list(TOOLTIP_CSS_SETUP, "")
-
- dat += "Character Settings"
- dat += "Antagonist Preferences"
- dat += "Game Preferences"
- var/shop_name = "[CONFIG_GET(string/metacurrency_name)] Shop"
- dat += "[shop_name]"
- dat += "OOC Preferences"
-
- dat += ""
-
- dat += " "
-
- switch(current_tab)
- if (0) // Character Settings#
- dat += ""
- var/name
- var/unspaced_slots = 0
- for(var/datum/character_save/CS as anything in character_saves)
- unspaced_slots++
- if(unspaced_slots > 4)
- dat += " "
- unspaced_slots = 0
- name = CS.real_name
- if(!name)
- name = "Character [CS.slot_number]"
- if(CS.slot_locked)
- dat += "[name] (Locked) "
- else
- dat += "[name] "
- dat += ""
-
- dat += "Occupation Choices"
- dat += "Set Occupation Preferences "
- if(CONFIG_GET(flag/roundstart_traits))
- dat += "Quirk Setup"
- dat += "Configure Quirks "
- dat += "Current Quirks: [length(active_character.all_quirks) ? active_character.all_quirks.Join(", ") : "None"]"
- dat += "Identity"
- dat += ""
-
- dat += "Body"
- dat += "Random Body "
- dat += "Always Random Body: [active_character.be_random_body ? "Yes" : "No"] "
-
- dat += ""
-
- dat += "Species: [active_character.pref_species.name] "
-
- dat += "Underwear: [active_character.underwear] "
- dat += "Underwear Color: Change "
- dat += "Undershirt: [active_character.undershirt] "
- dat += "Socks: [active_character.socks] "
- dat += "Backpack: [active_character.backbag] "
- dat += "Jumpsuit: [active_character.jumpsuit_style] "
- dat += "Uplink Spawn Location: [active_character.uplink_spawn_loc == UPLINK_IMPLANT ? UPLINK_IMPLANT_WITH_PRICE : active_character.uplink_spawn_loc]
| "
-
- var/use_skintones = active_character.pref_species.use_skintones
- if(use_skintones)
-
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Skin Tone"
-
- dat += "[active_character.skin_tone] "
-
- var/mutant_colors
- if((MUTCOLORS in active_character.pref_species.species_traits) || (MUTCOLORS_PARTSONLY in active_character.pref_species.species_traits))
-
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Mutant Color"
-
- dat += " Change "
-
- mutant_colors = TRUE
-
- if(istype(active_character.pref_species, /datum/species/ethereal)) //not the best thing to do tbf but I dont know whats better.
-
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Ethereal Color"
-
- dat += " Change "
-
- if(istype(active_character.pref_species, /datum/species/plasmaman))
-
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Envirohelmet Type"
-
- dat += "[active_character.helmet_style] "
-
- if((EYECOLOR in active_character.pref_species.species_traits) && !(NOEYESPRITES in active_character.pref_species.species_traits))
-
- if(!use_skintones && !mutant_colors)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Eye Color"
-
- dat += " Change "
-
- dat += ""
- else if(use_skintones || mutant_colors)
- dat += ""
-
- if(HAIR in active_character.pref_species.species_traits)
-
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Hair Style"
-
- dat += "[active_character.hair_style] "
- dat += "< > "
- dat += " Change "
-
- dat += "Gradient Style"
-
- dat += "[active_character.gradient_style] "
- dat += "< > "
- dat += " Change "
-
- dat += "Facial Hair Style"
-
- dat += "[active_character.facial_hair_style] "
- dat += "< > "
- dat += " Change "
-
- dat += ""
-
- //Mutant stuff
- var/mutant_category = 0
-
- if("tail_lizard" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Tail"
-
- dat += "[active_character.features["tail_lizard"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("snout" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Snout"
-
- dat += "[active_character.features["snout"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("horns" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Horns"
-
- dat += "[active_character.features["horns"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("frills" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Frills"
-
- dat += "[active_character.features["frills"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("spines" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Spines"
-
- dat += "[active_character.features["spines"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("body_markings" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Body Markings"
-
- dat += "[active_character.features["body_markings"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("legs" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Legs"
-
- dat += "[active_character.features["legs"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("moth_wings" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Moth wings"
-
- dat += "[active_character.features["moth_wings"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("moth_antennae" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Moth antennae"
-
- dat += "[active_character.features["moth_antennae"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("moth_markings" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Moth markings"
-
- dat += "[active_character.features["moth_markings"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("ipc_screen" in active_character.pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Screen Style"
-
- dat += "[active_character.features["ipc_screen"]] "
-
- dat += " Change "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("ipc_antenna" in active_character.pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Antenna Style"
-
- dat += "[active_character.features["ipc_antenna"]] "
-
- dat += " Change "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("ipc_chassis" in active_character.pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Chassis Style"
-
- dat += "[active_character.features["ipc_chassis"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("tail_human" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Tail"
-
- dat += "[active_character.features["tail_human"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("insect_type" in active_character.pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Insect Type"
-
- dat += "[active_character.features["insect_type"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("psyphoza_cap" in active_character.pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Cap Type"
-
- dat += "[active_character.features["psyphoza_cap"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("apid_antenna" in active_character.pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Antenna Style"
-
- dat += "[active_character.features["apid_antenna"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("apid_stripes" in active_character.pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Stripe Pattern"
-
- dat += "[active_character.features["apid_stripes"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("apid_headstripes" in active_character.pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Headstripe Pattern"
-
- dat += "[active_character.features["apid_headstripes"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("ears" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Ears"
-
- dat += "[active_character.features["ears"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("body_size" in active_character.pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Size"
-
- dat += "[active_character.features["body_size"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if(CONFIG_GET(flag/join_with_mutant_humans))
-
- if("wings" in active_character.pref_species.default_features && GLOB.r_wings_list.len >1)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Wings"
-
- dat += "[active_character.features["wings"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if(mutant_category)
- dat += ""
- mutant_category = 0
- dat += " "
-
-
- if (1) // Game Preferences
- dat += ""
-
- if(4) // antagonist preferences window
- dat += ""
- var/name
- var/unspaced_slots = 0
- for(var/datum/character_save/CS as anything in character_saves)
- unspaced_slots++
- if(unspaced_slots > 4)
- dat += " "
- unspaced_slots = 0
- name = CS.real_name
- if(!name)
- name = "Character [CS.slot_number]"
- if(CS.slot_locked)
- dat += "[name] (Locked) "
- else
- dat += "[name] "
- dat += ""
- dat += ""
- //
- dat += ""
- // --------------------------------------------
- // warning pannel
- var/ban_antagonists = is_banned_from(parent.ckey, BAN_ROLE_ALL_ANTAGONISTS)
- var/ban_forced_antagonists = is_banned_from(parent.ckey, BAN_ROLE_FORCED_ANTAGONISTS)
- var/ban_ghost = is_banned_from(parent.ckey, BAN_ROLE_ALL_GHOST)
- if(ban_antagonists || ban_forced_antagonists || ban_ghost)
- dat += "Notification"
- if(ban_antagonists)
- dat += "You are banned from all antagonist roles. \
- Show Info "
- if(ban_forced_antagonists)
- dat += "You are banned from all forced antagonist roles (such as brainwashing). \
- Show Info "
- if(ban_ghost)
- dat += "You are banned from all non-antagonist ghost roles. \
- Show Info "
- // --------------------------------------------
- // Antagonist roles
- dat += "Antagonists"
- for (var/typepath in GLOB.role_preference_entries)
- var/datum/role_preference/pref = GLOB.role_preference_entries[typepath]
- if(pref.category != ROLE_PREFERENCE_CATEGORY_ANAGONIST)
- continue
- var/ban_key = initial(pref.antag_datum.banning_key)
- if(is_banned_from(parent.ckey, ban_key))
- dat += "[pref.name]: BANNED "
- else
- dat += "[pref.name] \
- - Character: [parent.role_preference_enabled(typepath) ? "Enabled" : "Disabled"]\
- - Global: Enable\
- Disable "
- dat += " | "
- // left box closed
-
- //
- // --------------------------------------------
- // Midround antagonists + ghostspawn roles
- dat += ""
- dat += "Midrounds (Living)"
- for (var/typepath in GLOB.role_preference_entries)
- var/datum/role_preference/pref = GLOB.role_preference_entries[typepath]
- if(pref.category != ROLE_PREFERENCE_CATEGORY_MIDROUND_LIVING)
- continue
- var/ban_key = initial(pref.antag_datum.banning_key)
- if(is_banned_from(parent.ckey, ban_key))
- dat += "[pref.name]: BANNED "
- else
- dat += "[pref.name] \
- - Character: [parent.role_preference_enabled(typepath) ? "Enabled" : "Disabled"]\
- - Global: Enable\
- Disable "
- dat += "Midrounds (Ghost)"
- for (var/typepath in GLOB.role_preference_entries)
- var/datum/role_preference/pref = GLOB.role_preference_entries[typepath]
- if(pref.category != ROLE_PREFERENCE_CATEGORY_MIDROUND_GHOST)
- continue
- var/ban_key = initial(pref.antag_datum.banning_key)
- if(is_banned_from(parent.ckey, ban_key))
- dat += "[pref.name]: BANNED "
- else
- dat += "[pref.name]: [parent.role_preference_enabled(typepath) ? "Enabled" : "Disabled"] "
- dat += " | "
- // right box closed
-
- dat += " "
-
- if(2) //Loadout
- var/list/type_blacklist = list()
- if(length(active_character.equipped_gear))
- for(var/i in 1 to length(active_character.equipped_gear))
- var/datum/gear/G = GLOB.gear_datums[active_character.equipped_gear[i]]
- if(G)
- if(G.subtype_path in type_blacklist)
- continue
- type_blacklist += G.subtype_path
- else
- active_character.equipped_gear.Cut(i,i+1)
-
- dat += ""
- var/name
- var/unspaced_slots = 0
- for(var/datum/character_save/CS as anything in character_saves)
- unspaced_slots++
- if(unspaced_slots > 4)
- dat += " "
- unspaced_slots = 0
- name = CS.real_name
- if(!name)
- name = "Character [CS.slot_number]"
- if(CS.slot_locked)
- dat += "[name] (Locked) "
- else
- dat += "[name] "
- dat += ""
-
- var/fcolor = "#3366CC"
- var/metabalance = user.client.get_metabalance_db()
- dat += ""
- dat += "Current balance: [metabalance] [CONFIG_GET(string/metacurrency_name)]s. \[Clear Loadout\] | "
- dat += ""
-
- var/firstcat = 1
- for(var/category in GLOB.loadout_categories)
- if(category == "Donator" && (!LAZYLEN(GLOB.patrons) || !CONFIG_GET(flag/donator_items)))
- continue
- if(firstcat)
- firstcat = 0
- else
- dat += " |"
- if(category == gear_tab)
- dat += " [category] "
- else
- dat += " [category] "
- dat += " | "
-
- var/datum/loadout_category/LC = GLOB.loadout_categories[gear_tab]
- dat += "
| "
- dat += "[LC.category] | "
- dat += "
| "
-
- dat += "
| "
- dat += "Name | "
- if(LC.category != "Donator")
- dat += "Cost | "
- dat += "Restricted Jobs | "
- dat += "Description | "
- dat += "
| "
- for(var/gear_id in LC.gear)
- var/datum/gear/G = LC.gear[gear_id]
- var/ticked = (G.id in active_character.equipped_gear)
-
- if(active_character.jumpsuit_style == PREF_SKIRT && !isnull(G.skirt_display_name))
- dat += "[G.skirt_display_name]\n"
- else
- dat += " | [G.display_name]\n"
- var/donator = G.sort_category == "Donator" // purchase box and cost coloumns doesn't appear on donator items
- if(G.id in purchased_gear)
- if(G.sort_category == "OOC")
- dat += "Purchased. | "
- else
- dat += "Equip"
- else
- dat += "[donator ? "Donator" : "Purchase"]"
- dat += "[donator ? "" : "[G.cost]"] | "
-
- if(G.allowed_roles)
- dat += ""
- for(var/role in G.allowed_roles)
- dat += role + ", "
- dat += ""
- if(active_character.jumpsuit_style == PREF_SKIRT && !isnull(G.skirt_path))
- dat += " | [G.skirt_description] | "
- else
- dat += "[G.description] | "
- dat += " "
-
- if(3) //OOC Preferences
- dat += ""
-
- dat += " "
-
- if(!IS_GUEST_KEY(user.key))
- dat += "Undo "
- dat += "Save Setup "
-
- dat += "Reset Setup"
- dat += ""
-
- winshow(user, "preferences_window", TRUE)
- var/datum/browser/popup = new(user, "preferences_browser", "Character Setup ", 640, 830)
- popup.set_content(dat.Join())
- popup.open(FALSE)
- onclose(user, "preferences_window", src)
-
-#undef APPEARANCE_CATEGORY_COLUMN
-#undef MAX_MUTANT_ROWS
-
-/datum/preferences/proc/SetChoices(mob/user, limit = 16, list/splitJobs = list(JOB_NAME_CLOWN, JOB_NAME_RESEARCHDIRECTOR), widthPerColumn = 295, height = 620)
- if(!SSjob)
- return
-
- //limit - The amount of jobs allowed per column. Defaults to 17 to make it look nice.
- //splitJobs - Allows you split the table by job. You can make different tables for each department by including their heads. Defaults to CE to make it look nice.
- //widthPerColumn - Screen's width for every column.
- //height - Screen's height.
-
- var/width = widthPerColumn
-
- var/HTML = ""
- if(SSjob.occupations.len <= 0)
- HTML += "The job SSticker is not yet finished creating jobs, please try again later"
- HTML += "Done " // Easier to press up here.
-
- else
- HTML += "Choose occupation chances "
- HTML += "Left-click to raise an occupation preference, right-click to lower it.
"
- HTML += "Done " // Easier to press up here.
- HTML += ""
- HTML += "" // Table within a table for alignment, also allows you to easily add more colomns.
- HTML += ""
- var/index = -1
-
- //The job before the current job. I only use this to get the previous jobs color when I'm filling in blank rows.
- var/datum/job/lastJob
-
- var/datum/job/overflow = SSjob.GetJob(SSjob.overflow_role)
-
- for(var/datum/job/job in sort_list(SSjob.occupations, GLOBAL_PROC_REF(cmp_job_display_asc)))
- if(job.gimmick) //Gimmick jobs run off of a single pref
- continue
- index += 1
- if((index >= limit) || (job.title in splitJobs))
- width += widthPerColumn
- if((index < limit) && (lastJob != null))
- //If the cells were broken up by a job in the splitJob list then it will fill in the rest of the cells with
- //the last job's selection color. Creating a rather nice effect.
- for(var/i = 0, i < (limit - index), i += 1)
- HTML += "  |   | "
- HTML += " | "
- index = 0
-
- HTML += ""
- var/rank = job.title
- lastJob = job
- if(is_banned_from(user.ckey, rank))
- HTML += "[rank] | BANNED | "
- continue
- var/required_playtime_remaining = job.required_playtime_remaining(user.client)
- if(required_playtime_remaining)
- HTML += "[rank] \[ [get_exp_format(required_playtime_remaining)] as [job.get_exp_req_type()] \] | "
- continue
- if(!job.player_old_enough(user.client))
- var/available_in_days = job.available_in_days(user.client)
- HTML += "[rank] \[IN [(available_in_days)] DAYS\] | "
- continue
- if((active_character.job_preferences[overflow] == JP_LOW) && (rank != SSjob.overflow_role) && !is_banned_from(user.ckey, SSjob.overflow_role))
- HTML += "[rank] | "
- continue
- if((rank in GLOB.command_positions) || (rank == JOB_NAME_AI))//Bold head jobs
- HTML += "[rank]"
- else
- HTML += "[rank]"
-
- HTML += ""
-
- var/prefLevelLabel = "ERROR"
- var/prefLevelColor = "pink"
- var/prefUpperLevel = -1 // level to assign on left click
- var/prefLowerLevel = -1 // level to assign on right click
-
- switch(active_character.job_preferences[job.title])
- if(JP_HIGH)
- prefLevelLabel = "High"
- prefLevelColor = "slateblue"
- prefUpperLevel = 4
- prefLowerLevel = 2
- if(JP_MEDIUM)
- prefLevelLabel = "Medium"
- prefLevelColor = "green"
- prefUpperLevel = 1
- prefLowerLevel = 3
- if(JP_LOW)
- prefLevelLabel = "Low"
- prefLevelColor = "orange"
- prefUpperLevel = 2
- prefLowerLevel = 4
- else
- prefLevelLabel = "NEVER"
- prefLevelColor = "red"
- prefUpperLevel = 3
- prefLowerLevel = 1
-
- HTML += ""
-
- if(rank == SSjob.overflow_role)//Overflow is special
- if(active_character.job_preferences[overflow.title] == JP_LOW)
- HTML += "Yes"
- else
- HTML += "No"
- HTML += " | "
- continue
-
- HTML += "[prefLevelLabel]"
- HTML += ""
-
- for(var/i = 1, i < (limit - index), i += 1) // Finish the column so it is even
- HTML += "  |   | "
-
- HTML += " "
- HTML += " | "
-
- var/message = "Be an [SSjob.overflow_role] if preferences unavailable"
- if(active_character.joblessrole == BERANDOMJOB)
- message = "Get random job if preferences unavailable"
- else if(active_character.joblessrole == RETURNTOLOBBY)
- message = "Return to lobby if preferences unavailable"
- HTML += " [message]"
- HTML += "Reset Preferences"
-
- var/datum/browser/popup = new(user, "mob_occupation", "Occupation Preferences ", width, height)
- popup.set_window_options("can_close=0")
- popup.set_content(HTML)
- popup.open(FALSE)
-
-
-/datum/preferences/proc/ShowKeybindings(mob/user)
- // Create an inverted list of keybindings -> key
- var/list/user_binds = list()
- for(var/key in key_bindings)
- for(var/kb_name in key_bindings[key])
- user_binds[kb_name] = key
-
- var/list/kb_categories = list()
- // Group keybinds by category
- for (var/name in GLOB.keybindings_by_name)
- var/datum/keybinding/kb = GLOB.keybindings_by_name[name]
- if (!(kb.category in kb_categories))
- kb_categories[kb.category] = list()
- kb_categories[kb.category] += list(kb)
-
- var/HTML = ""
-
- for (var/category in kb_categories)
- HTML += "[category]"
- for (var/i in kb_categories[category])
- var/datum/keybinding/kb = i
- var/bound_key = user_binds[kb.name]
- bound_key = (bound_key) ? bound_key : "Unbound"
-
- HTML += " [bound_key] Default: ( [kb.key] )"
- HTML += " "
-
- HTML += "
"
- HTML += "Close"
- HTML += "Reset to default"
- HTML += ""
-
- winshow(user, "keybindings", TRUE)
- var/datum/browser/popup = new(user, "keybindings", "Keybindings ", 500, 900)
- popup.set_content(HTML)
- popup.open(FALSE)
- onclose(user, "keybindings", src)
-
-
-/datum/preferences/proc/CaptureKeybinding(mob/user, datum/keybinding/kb, var/old_key)
- var/HTML = {"
- Keybinding: [kb.full_name] [kb.description]
Press any key to change Press ESC to clear
-
- "}
- winshow(user, "capturekeypress", TRUE)
- var/datum/browser/popup = new(user, "capturekeypress", "Keybindings ", 350, 300)
- popup.set_content(HTML)
- popup.open(FALSE)
- onclose(user, "capturekeypress", src)
-
-
-/datum/preferences/proc/SetJobPreferenceLevel(datum/job/job, level)
- if (!job)
- return FALSE
-
- if (level == JP_HIGH) // to high
- //Set all other high to medium
- for(var/j in active_character.job_preferences)
- if(active_character.job_preferences[j] == JP_HIGH)
- active_character.job_preferences[j] = JP_MEDIUM
- //technically break here
-
- active_character.job_preferences[job.title] = level
- return TRUE
-
-
-
-
-/datum/preferences/proc/UpdateJobPreference(mob/user, role, desiredLvl)
- if(!SSjob || SSjob.occupations.len <= 0)
- return
- var/datum/job/job = SSjob.GetJob(role)
-
- if(!job)
- user << browse(null, "window=mob_occupation")
- ShowChoices(user)
- return
-
- if (!isnum_safe(desiredLvl))
- to_chat(user, "UpdateJobPreference - desired level was not a number. Please notify coders!")
- ShowChoices(user)
- return
-
- var/jpval = null
- switch(desiredLvl)
- if(3)
- jpval = JP_LOW
- if(2)
- jpval = JP_MEDIUM
- if(1)
- jpval = JP_HIGH
-
- if(role == SSjob.overflow_role)
- if(active_character.job_preferences[job.title] == JP_LOW)
- jpval = null
- else
- jpval = JP_LOW
-
- SetJobPreferenceLevel(job, jpval)
- SetChoices(user)
-
- return 1
-
-
-/datum/preferences/proc/ResetJobs()
- active_character.job_preferences = list()
-
-/datum/preferences/proc/SetQuirks(mob/user)
- if(!SSquirks)
- to_chat(user, "The quirk subsystem is still initializing! Try again in a minute.")
- return
-
- var/list/dat = list()
- if(!SSquirks.quirks.len)
- dat += "The quirk subsystem hasn't finished initializing, please hold..."
- dat += "Done "
- else
- dat += "Choose quirk setup "
- dat += "Left-click to add or remove quirks. You need negative quirks to have positive ones. \
- Quirks are applied at roundstart and cannot normally be removed. "
- dat += "Done"
- dat += " "
- dat += "Current quirks: [length(active_character.all_quirks) ? active_character.all_quirks.Join(", ") : "None"]"
- dat += "[GetPositiveQuirkCount()] / [MAX_QUIRKS] max positive quirks \
- Quirk balance remaining: [GetQuirkBalance()] "
- for(var/V in SSquirks.quirks)
- var/datum/quirk/T = SSquirks.quirks[V]
- var/quirk_name = initial(T.name)
- var/has_quirk
- var/quirk_cost = initial(T.value) * -1
- var/lock_reason = "This trait is unavailable."
- var/quirk_conflict = FALSE
- for(var/_V in active_character.all_quirks)
- if(_V == quirk_name)
- has_quirk = TRUE
- if(initial(T.mood_quirk) && CONFIG_GET(flag/disable_human_mood))
- lock_reason = "Mood is disabled."
- quirk_conflict = TRUE
- if(has_quirk)
- if(quirk_conflict)
- active_character.all_quirks -= quirk_name
- has_quirk = FALSE
- else
- quirk_cost *= -1 //invert it back, since we'd be regaining this amount
- if(quirk_cost > 0)
- quirk_cost = "+[quirk_cost]"
- var/font_color = "#AAAAFF"
- if(initial(T.value) != 0)
- font_color = initial(T.value) > 0 ? "#AAFFAA" : "#FFAAAA"
- if(quirk_conflict)
- dat += "[quirk_name] - [initial(T.desc)] \
- LOCKED: [lock_reason] "
- else
- if(has_quirk)
- dat += "[has_quirk ? "Remove" : "Take"] ([quirk_cost] pts.) \
- [quirk_name] - [initial(T.desc)] "
- else
- dat += "[has_quirk ? "Remove" : "Take"] ([quirk_cost] pts.) \
- [quirk_name] - [initial(T.desc)] "
- dat += " Reset Quirks"
-
- var/datum/browser/popup = new(user, "mob_occupation", "Quirk Preferences ", 900, 600) //no reason not to reuse the occupation window, as it's cleaner that way
- popup.set_window_options("can_close=0")
- popup.set_content(dat.Join())
- popup.open(FALSE)
-
-/datum/preferences/proc/GetQuirkBalance()
- var/bal = 0
- for(var/V in active_character.all_quirks)
- var/datum/quirk/T = SSquirks.quirks[V]
- bal -= initial(T.value)
- return bal
-
-/datum/preferences/proc/GetPositiveQuirkCount()
- . = 0
- for(var/q in active_character.all_quirks)
- if(SSquirks.quirk_points[q] > 0)
- .++
-
-/datum/preferences/Topic(href, href_list, hsrc) //yeah, gotta do this I guess..
- . = ..()
- if(href_list["close"])
- var/client/C = usr.client
- if(C)
- C.clear_character_previews()
-
-/datum/preferences/proc/process_link(mob/user, list/href_list)
- if(href_list["bancheck"])
- var/list/ban_details = is_banned_from_with_details(user.ckey, user.client.address, user.client.computer_id, href_list["bancheck"])
- var/admin = FALSE
- if(GLOB.admin_datums[user.ckey] || GLOB.deadmins[user.ckey])
- admin = TRUE
- for(var/i in ban_details)
- if(admin && !text2num(i["applies_to_admins"]))
- continue
- ban_details = i
- break //we only want to get the most recent ban's details
- if(ban_details && ban_details.len)
- var/expires = "This is a permanent ban."
- if(ban_details["expiration_time"])
- expires = " The ban is for [DisplayTimeText(text2num(ban_details["duration"]) MINUTES)] and expires on [ban_details["expiration_time"]] (server time)."
- to_chat(user, "You, or another user of this computer or connection ([ban_details["key"]]) is banned from playing [href_list["bancheck"]]. The ban reason is: [ban_details["reason"]] This ban (BanID #[ban_details["id"]]) was applied by [ban_details["admin_key"]] on [ban_details["bantime"]] during round ID [ban_details["round_id"]]. [expires]")
- return
- if(href_list["preference"] == "job")
- switch(href_list["task"])
- if("close")
- user << browse(null, "window=mob_occupation")
- ShowChoices(user)
- if("reset")
- ResetJobs()
- SetChoices(user)
- if("random")
- switch(active_character.joblessrole)
- if(RETURNTOLOBBY)
- if(is_banned_from(user.ckey, SSjob.overflow_role))
- active_character.joblessrole = BERANDOMJOB
- else
- active_character.joblessrole = BEOVERFLOW
- if(BEOVERFLOW)
- active_character.joblessrole = BERANDOMJOB
- if(BERANDOMJOB)
- active_character.joblessrole = RETURNTOLOBBY
- SetChoices(user)
- if("setJobLevel")
- UpdateJobPreference(user, href_list["text"], text2num(href_list["level"]))
- else
- SetChoices(user)
- return 1
-
- else if(href_list["preference"] == "trait")
- switch(href_list["task"])
- if("close")
- user << browse(null, "window=mob_occupation")
- ShowChoices(user)
- if("update")
- var/quirk = href_list["trait"]
- if(!SSquirks.quirks[quirk])
- return
- for(var/V in SSquirks.quirk_blacklist) //V is a list
- var/list/L = V
- for(var/Q in active_character.all_quirks)
- if((quirk in L) && (Q in L) && !(Q == quirk)) //two quirks have lined up in the list of the list of quirks that conflict with each other, so return (see quirks.dm for more details)
- to_chat(user, "[quirk] is incompatible with [Q].")
- return
- var/value = SSquirks.quirk_points[quirk]
- var/balance = GetQuirkBalance()
- if(quirk in active_character.all_quirks)
- if(balance + value < 0)
- to_chat(user, "Refunding this would cause you to go below your balance!")
- return
- active_character.all_quirks -= quirk
- else
- var/is_positive_quirk = SSquirks.quirk_points[quirk] > 0
- if(is_positive_quirk && GetPositiveQuirkCount() >= MAX_QUIRKS)
- to_chat(user, "You can't have more than [MAX_QUIRKS] positive quirks!")
- return
- if(balance - value < 0)
- to_chat(user, "You don't have enough balance to gain this quirk!")
- return
- active_character.all_quirks += quirk
- SetQuirks(user)
- if("reset")
- active_character.all_quirks = list()
- SetQuirks(user)
- else
- SetQuirks(user)
- return TRUE
-
- if(href_list["preference"] == "gear")
- if(href_list["purchase_gear"])
- var/datum/gear/TG = GLOB.gear_datums[href_list["purchase_gear"]]
- if(TG.sort_category == "Donator")
- if(CONFIG_GET(flag/donator_items) && alert(parent, "This item is only accessible to our patrons. Would you like to subscribe?", "Patron Locked", "Yes", "No") == "Yes")
- parent.donate()
- else if(TG.cost <= user.client.get_metabalance_db())
- purchased_gear += TG.id
- TG.purchase(user.client)
- user.client.inc_metabalance((TG.cost * -1), TRUE, "Purchased [TG.display_name].")
- save_preferences()
- else
- to_chat(user, "You don't have enough [CONFIG_GET(string/metacurrency_name)]s to purchase \the [TG.display_name]!")
- if(href_list["toggle_gear"])
- var/datum/gear/TG = GLOB.gear_datums[href_list["toggle_gear"]]
- if(TG.id in active_character.equipped_gear)
- active_character.equipped_gear -= TG.id
- else
- var/list/type_blacklist = list()
- var/list/slot_blacklist = list()
- for(var/gear_id in active_character.equipped_gear)
- var/datum/gear/G = GLOB.gear_datums[gear_id]
- if(istype(G))
- if(!(G.subtype_path in type_blacklist))
- type_blacklist += G.subtype_path
- if(!(G.slot in slot_blacklist))
- slot_blacklist += G.slot
- if((TG.id in purchased_gear))
- if(!(TG.subtype_path in type_blacklist) || !(TG.slot in slot_blacklist))
- active_character.equipped_gear += TG.id
- else
- to_chat(user, "Can't equip [TG.display_name]. It conflicts with an already-equipped item.")
- else
- log_href_exploit(user)
- active_character.save(user.client)
-
- else if(href_list["select_category"])
- gear_tab = href_list["select_category"]
- else if(href_list["clear_loadout"])
- active_character.equipped_gear.Cut()
- active_character.save(user.client)
-
- ShowChoices(user)
- return
-
- switch(href_list["task"])
- if("random")
- switch(href_list["preference"])
- if("name")
- active_character.real_name = active_character.pref_species.random_name(active_character.gender, 1)
- if("age")
- active_character.age = rand(AGE_MIN, AGE_MAX)
- if("hair_color")
- active_character.hair_color = random_short_color()
- if("hair_style")
- active_character.hair_style = random_hair_style(active_character.gender)
- if("facial")
- active_character.facial_hair_color = random_short_color()
- if("facial_hair_style")
- active_character.facial_hair_style = random_facial_hair_style(active_character.gender)
- if("underwear")
- active_character.underwear = random_underwear(active_character.gender)
- if("underwear_color")
- active_character.underwear_color = random_short_color()
- if("undershirt")
- active_character.undershirt = random_undershirt(active_character.gender)
- if("socks")
- active_character.socks = random_socks()
- if(BODY_ZONE_PRECISE_EYES)
- active_character.eye_color = random_eye_color()
- if("s_tone")
- active_character.skin_tone = random_skin_tone()
- if("bag")
- active_character.backbag = pick(GLOB.backbaglist)
- if("all")
- active_character.randomise()
-
- if("input")
-
- if(href_list["preference"] in GLOB.preferences_custom_names)
- ask_for_custom_name(user,href_list["preference"])
-
- if(href_list["preference"] in active_character.pref_species.forced_features)
- alert("You cannot change that bodypart for your selected species!")
- active_character.features[href_list["preference"]] = active_character.pref_species.forced_features[href_list["preference"]]
- return
-
- switch(href_list["preference"])
- if("ghostform")
- if(unlock_content)
- var/new_form = input(user, "Thanks for supporting BYOND - Choose your ghostly form:","Thanks for supporting BYOND",null) as null|anything in GLOB.ghost_forms
- if(new_form)
- ghost_form = new_form
- if("ghostorbit")
- if(unlock_content)
- var/new_orbit = input(user, "Thanks for supporting BYOND - Choose your ghostly orbit:","Thanks for supporting BYOND", null) as null|anything in GLOB.ghost_orbits
- if(new_orbit)
- ghost_orbit = new_orbit
-
- if("ghostaccs")
- var/new_ghost_accs = alert("Do you want your ghost to show full accessories where possible, hide accessories but still use the directional sprites where possible, or also ignore the directions and stick to the default sprites?",,GHOST_ACCS_FULL_NAME, GHOST_ACCS_DIR_NAME, GHOST_ACCS_NONE_NAME)
- switch(new_ghost_accs)
- if(GHOST_ACCS_FULL_NAME)
- ghost_accs = GHOST_ACCS_FULL
- if(GHOST_ACCS_DIR_NAME)
- ghost_accs = GHOST_ACCS_DIR
- if(GHOST_ACCS_NONE_NAME)
- ghost_accs = GHOST_ACCS_NONE
-
- if("ghostothers")
- var/new_ghost_others = alert("Do you want the ghosts of others to show up as their own setting, as their default sprites or always as the default white ghost?",,GHOST_OTHERS_THEIR_SETTING_NAME, GHOST_OTHERS_DEFAULT_SPRITE_NAME, GHOST_OTHERS_SIMPLE_NAME)
- switch(new_ghost_others)
- if(GHOST_OTHERS_THEIR_SETTING_NAME)
- ghost_others = GHOST_OTHERS_THEIR_SETTING
- if(GHOST_OTHERS_DEFAULT_SPRITE_NAME)
- ghost_others = GHOST_OTHERS_DEFAULT_SPRITE
- if(GHOST_OTHERS_SIMPLE_NAME)
- ghost_others = GHOST_OTHERS_SIMPLE
-
- if("name")
- var/new_name = reject_bad_name( input(user, "Choose your character's name:", "Character Preference") as text|null , active_character.pref_species.allow_numbers_in_name)
- if(new_name)
- active_character.real_name = new_name
- else
- to_chat(user, "Invalid name. Your name should be at least 2 and at most [MAX_NAME_LEN] characters long. It may only contain the characters A-Z, a-z, -, ' and .")
-
- if("age")
- var/new_age = input(user, "Choose your character's age:\n([AGE_MIN]-[AGE_MAX])", "Character Preference") as num|null
- if(new_age)
- active_character.age = max(min( round(text2num(new_age)), AGE_MAX),AGE_MIN)
-
- if("hair_color")
- var/new_hair = tgui_color_picker(user, "Choose your character's hair colour:", "Character Preference", "#" + active_character.hair_color)
- if(new_hair)
- active_character.hair_color = sanitize_hexcolor(new_hair)
-
- if("hair_style")
- var/new_hair_style = tgui_input_list(user, "Choose your character's hair style:", "Character Preference", GLOB.hair_styles_list, active_character.hair_style)
- if(new_hair_style)
- active_character.hair_style = new_hair_style
-
- if("gradient_style")
- var/new_gradient_style
- new_gradient_style = input(user, "Choose your character's hair gradient style:", "Character Preference") as null|anything in GLOB.hair_gradients_list
- if(new_gradient_style)
- active_character.gradient_style = new_gradient_style
-
- if("gradient_color")
- var/new_hair_gradient = tgui_color_picker(user, "Choose your character's hair gradient colour:", "Character Preference", "#" + active_character.gradient_color)
- if(new_hair_gradient)
- active_character.gradient_color = sanitize_hexcolor(new_hair_gradient)
-
- if("next_hair_style")
- active_character.hair_style = next_list_item(active_character.hair_style, GLOB.hair_styles_list)
-
- if("previous_hair_style")
- active_character.hair_style = previous_list_item(active_character.hair_style, GLOB.hair_styles_list)
-
- if("next_gradient_style")
- active_character.gradient_style = next_list_item(active_character.gradient_style, GLOB.hair_gradients_list)
-
- if("previous_gradient_style")
- active_character.gradient_style = previous_list_item(active_character.gradient_style, GLOB.hair_gradients_list)
-
- if("facial")
- var/new_facial = tgui_color_picker(user, "Choose your character's facial-hair colour:", "Character Preference","#" + active_character.facial_hair_color)
- if(new_facial)
- active_character.facial_hair_color = sanitize_hexcolor(new_facial)
-
- if("facial_hair_style")
- var/new_facial_hair_style = tgui_input_list(user, "Choose your character's facial-hair style:", "Character Preference", GLOB.facial_hair_styles_list, active_character.facial_hair_style)
- if(new_facial_hair_style)
- active_character.facial_hair_style = new_facial_hair_style
-
- if("next_facehair_style")
- active_character.facial_hair_style = next_list_item(active_character.facial_hair_style, GLOB.facial_hair_styles_list)
-
- if("previous_facehair_style")
- active_character.facial_hair_style = previous_list_item(active_character.facial_hair_style, GLOB.facial_hair_styles_list)
-
- if("underwear")
- var/new_underwear = tgui_input_list(user, "Choose your character's underwear:", "Character Preference", GLOB.underwear_list, active_character.underwear)
- if(new_underwear)
- active_character.underwear = new_underwear
-
- if("underwear_color")
- var/new_underwear_color = tgui_color_picker(user, "Choose your character's underwear color:", "Character Preference","#"+active_character.underwear_color)
- if(new_underwear_color)
- active_character.underwear_color = sanitize_hexcolor(new_underwear_color)
-
- if("undershirt")
- var/new_undershirt = tgui_input_list(user, "Choose your character's undershirt:", "Character Preference", GLOB.undershirt_list, active_character.undershirt)
- if(new_undershirt)
- active_character.undershirt = new_undershirt
-
- if("socks")
- var/new_socks
- new_socks = tgui_input_list(user, "Choose your character's socks:", "Character Preference", GLOB.socks_list, active_character.socks)
- if(new_socks)
- active_character.socks = new_socks
-
- if("eyes")
- var/new_eyes = tgui_color_picker(user, "Choose your character's eye colour:", "Character Preference","#"+active_character.eye_color)
- if(new_eyes)
- active_character.eye_color = sanitize_hexcolor(new_eyes)
-
- if("body_size")
- var/new_size = input(user, "Choose your character's height:", "Character Preference") as null|anything in GLOB.body_sizes
- if(new_size)
- active_character.features["body_size"] = new_size
-
- if("species")
-
- var/result = input(user, "Select a species", "Species Selection") as null|anything in GLOB.roundstart_races
-
- if(result)
- var/new_species_type = GLOB.species_list[result]
- var/datum/species/new_species = new new_species_type()
-
- if (!CONFIG_GET(keyed_list/paywall_races)[new_species.id] || IS_PATRON(parent.ckey) || parent.holder)
- active_character.pref_species = new_species
- //Now that we changed our species, we must verify that the mutant colour is still allowed.
- var/temp_hsv = RGBtoHSV(active_character.features["mcolor"])
- if(active_character.features["mcolor"] == "#000" || (!(MUTCOLORS_PARTSONLY in active_character.pref_species.species_traits) && ReadHSV(temp_hsv)[3] < ReadHSV("#7F7F7F")[3]))
- active_character.features["mcolor"] = active_character.pref_species.default_color
- //Set our forced bodyparts
- for(var/forced_part in active_character.pref_species.forced_features)
- //Get the forced type
- var/forced_type = active_character.pref_species.forced_features[forced_part]
- //Apply the forced bodypart.
- active_character.features[forced_part] = forced_type
- else
- if(alert(parent, "This species is only accessible to our patrons. Would you like to subscribe?", "Patron Locked", "Yes", "No") == "Yes")
- parent.donate()
-
-
- if("mutant_color")
- var/new_mutantcolor = tgui_color_picker(user, "Choose your character's alien/mutant color:", "Character Preference","#"+active_character.features["mcolor"])
- if(new_mutantcolor)
- var/temp_hsv = RGBtoHSV(new_mutantcolor)
- if(new_mutantcolor == "#000000")
- active_character.features["mcolor"] = active_character.pref_species.default_color
- else if((MUTCOLORS_PARTSONLY in active_character.pref_species.species_traits) || ReadHSV(temp_hsv)[3] >= ReadHSV("#7F7F7F")[3]) // mutantcolors must be bright, but only if they affect the skin
- active_character.features["mcolor"] = sanitize_hexcolor(new_mutantcolor)
- else
- to_chat(user, "Invalid color. Your color is not bright enough.")
-
- if("color_ethereal")
- var/new_etherealcolor = input(user, "Choose your ethereal color", "Character Preference") as null|anything in GLOB.color_list_ethereal
- if(new_etherealcolor)
- active_character.features["ethcolor"] = GLOB.color_list_ethereal[new_etherealcolor]
-
- if("helmet_style")
- var/style = input(user, "Choose your helmet style", "Character Preference") as null|anything in list(HELMET_DEFAULT, HELMET_MK2, HELMET_PROTECTIVE)
- if(style)
- active_character.helmet_style = style
-
- if("tail_lizard")
- var/new_tail
- new_tail = input(user, "Choose your character's tail:", "Character Preference") as null|anything in GLOB.tails_list_lizard
- if(new_tail)
- active_character.features["tail_lizard"] = new_tail
-
- if("tail_human")
- var/new_tail
- new_tail = input(user, "Choose your character's tail:", "Character Preference") as null|anything in GLOB.tails_list_human
- if(new_tail)
- active_character.features["tail_human"] = new_tail
-
- if("snout")
- var/new_snout
- new_snout = input(user, "Choose your character's snout:", "Character Preference") as null|anything in GLOB.snouts_list
- if(new_snout)
- active_character.features["snout"] = new_snout
-
- if("horns")
- var/new_horns
- new_horns = input(user, "Choose your character's horns:", "Character Preference") as null|anything in GLOB.horns_list
- if(new_horns)
- active_character.features["horns"] = new_horns
-
- if("ears")
- var/new_ears
- new_ears = input(user, "Choose your character's ears:", "Character Preference") as null|anything in GLOB.ears_list
- if(new_ears)
- active_character.features["ears"] = new_ears
-
- if("wings")
- var/new_wings
- new_wings = input(user, "Choose your character's wings:", "Character Preference") as null|anything in GLOB.r_wings_list
- if(new_wings)
- active_character.features["wings"] = new_wings
-
- if("frills")
- var/new_frills
- new_frills = input(user, "Choose your character's frills:", "Character Preference") as null|anything in GLOB.frills_list
- if(new_frills)
- active_character.features["frills"] = new_frills
-
- if("spines")
- var/new_spines
- new_spines = input(user, "Choose your character's spines:", "Character Preference") as null|anything in GLOB.spines_list
- if(new_spines)
- active_character.features["spines"] = new_spines
-
- if("body_markings")
- var/new_body_markings
- new_body_markings = input(user, "Choose your character's body markings:", "Character Preference") as null|anything in GLOB.body_markings_list
- if(new_body_markings)
- active_character.features["body_markings"] = new_body_markings
-
- if("legs")
- var/new_legs
- new_legs = input(user, "Choose your character's legs:", "Character Preference") as null|anything in GLOB.legs_list
- if(new_legs)
- active_character.features["legs"] = new_legs
-
- if("moth_wings")
- var/new_moth_wings
- new_moth_wings = input(user, "Choose your character's wings:", "Character Preference") as null|anything in GLOB.moth_wings_roundstart_list
- if(new_moth_wings)
- active_character.features["moth_wings"] = new_moth_wings
-
- if("moth_antennae")
- var/new_moth_antennae
- new_moth_antennae = input(user, "Choose your character's antennae:", "Character Preference") as null|anything in GLOB.moth_antennae_roundstart_list
- if(new_moth_antennae)
- active_character.features["moth_antennae"] = new_moth_antennae
-
- if("moth_markings")
- var/new_moth_markings
- new_moth_markings = input(user, "Choose your character's markings:", "Character Preference") as null|anything in GLOB.moth_markings_roundstart_list
- if(new_moth_markings)
- active_character.features["moth_markings"] = new_moth_markings
-
- if("ipc_screen")
- var/new_ipc_screen
-
- new_ipc_screen = input(user, "Choose your character's screen:", "Character Preference") as null|anything in GLOB.ipc_screens_list
-
- if(new_ipc_screen)
- active_character.features["ipc_screen"] = new_ipc_screen
-
- if("ipc_antenna")
- var/new_ipc_antenna
-
- new_ipc_antenna = input(user, "Choose your character's antenna:", "Character Preference") as null|anything in GLOB.ipc_antennas_list
-
- if(new_ipc_antenna)
- active_character.features["ipc_antenna"] = new_ipc_antenna
-
- if("ipc_chassis")
- var/new_ipc_chassis
-
- new_ipc_chassis = input(user, "Choose your character's chassis:", "Character Preference") as null|anything in GLOB.ipc_chassis_list
-
- if(new_ipc_chassis)
- active_character.features["ipc_chassis"] = new_ipc_chassis
-
- if("insect_type")
- var/new_insect_type
-
- new_insect_type = input(user, "Choose your character's species:", "Character Preference") as null|anything in GLOB.insect_type_list
-
- if(new_insect_type)
- active_character.features["insect_type"] = new_insect_type
-
- if("psyphoza_cap")
- var/new_cap
- new_cap = input(user, "Choose your character's cap:", "Character Preference") as null|anything in GLOB.psyphoza_cap_list
- if(new_cap)
- active_character.features["psyphoza_cap"] = new_cap
-
- if("apid_antenna")
- var/new_apid_antenna
-
- new_apid_antenna = input(user, "Choose your apid antennae:", "Character Preference") as null|anything in GLOB.apid_antenna_list
-
- if(new_apid_antenna)
- active_character.features["apid_antenna"] = new_apid_antenna
-
- if("apid_stripes")
- var/new_apid_stripes
-
- new_apid_stripes = input(user, "Choose your apid stripes:", "Character Preference") as null|anything in GLOB.apid_stripes_list
-
- if(new_apid_stripes)
- active_character.features["apid_stripes"] = new_apid_stripes
-
- if("apid_headstripes")
- var/new_apid_headstripes
-
- new_apid_headstripes = input(user, "Choose your apid headstripes:", "Character Preference") as null|anything in GLOB.apid_headstripes_list
-
- if(new_apid_headstripes)
- active_character.features["apid_headstripes"] = new_apid_headstripes
-
- if("s_tone")
- var/new_s_tone = input(user, "Choose your character's skin-tone:", "Character Preference") as null|anything in GLOB.skin_tones
- if(new_s_tone)
- active_character.skin_tone = new_s_tone
-
- if("ooccolor")
- var/new_ooccolor = tgui_color_picker(user, "Choose your OOC colour:", "Game Preference",ooccolor)
- if(new_ooccolor)
- ooccolor = new_ooccolor
-
- if("asaycolor")
- var/new_asaycolor = tgui_color_picker(user, "Choose your ASAY color:", "Game Preference",asaycolor)
- if(new_asaycolor)
- asaycolor = sanitize_ooccolor(new_asaycolor)
-
- if("bag")
- var/new_backbag = input(user, "Choose your character's style of bag:", "Character Preference") as null|anything in GLOB.backbaglist
- if(new_backbag)
- active_character.backbag = new_backbag
-
- if("suit")
- if(active_character.jumpsuit_style == PREF_SUIT)
- active_character.jumpsuit_style = PREF_SKIRT
- else
- active_character.jumpsuit_style = PREF_SUIT
-
- if("uplink_loc")
- var/new_loc = input(user, "Choose your character's traitor uplink spawn location:", "Character Preference") as null|anything in GLOB.uplink_spawn_loc_list
- if(new_loc)
- // This is done to prevent affecting saves
- active_character.uplink_spawn_loc = new_loc == UPLINK_IMPLANT_WITH_PRICE ? UPLINK_IMPLANT : new_loc
-
- if("ai_core_icon")
- var/ai_core_icon = input(user, "Choose your preferred AI core display screen:", "AI Core Display Screen Selection") as null|anything in GLOB.ai_core_display_screens - "Portrait"
- if(ai_core_icon)
- active_character.preferred_ai_core_display = ai_core_icon
-
- if("sec_dept")
- var/department = input(user, "Choose your preferred security department:", "Security Departments") as null|anything in GLOB.security_depts_prefs
- if(department)
- active_character.preferred_security_department = department
-
- if ("preferred_map")
- var/maplist = list()
- var/default = "Default"
- if (config.defaultmap)
- default += " ([config.defaultmap.map_name])"
- for (var/M in config.maplist)
- var/datum/map_config/VM = config.maplist[M]
- if(!VM.votable || SSmapping.config.map_name == VM.map_name) //current map will be excluded from the vote
- continue
- var/friendlyname = "[VM.map_name] "
- if (VM.voteweight <= 0)
- friendlyname += " (disabled)"
- maplist[friendlyname] = VM.map_name
- maplist[default] = null
- var/pickedmap = input(user, "Choose your preferred map. This will be used to help weight random map selection.", "Character Preference") as null|anything in sort_list(maplist)
- if (pickedmap)
- preferred_map = maplist[pickedmap]
-
- if ("clientfps")
- var/desiredfps = input(user, "Choose your desired fps. (0 = synced with server tick rate (currently:[world.fps]))", "Character Preference", clientfps) as null|num
- if (!isnull(desiredfps))
- clientfps = desiredfps
- parent.fps = desiredfps
- if("ui")
- var/pickedui = input(user, "Choose your UI style.", "Character Preference", UI_style) as null|anything in GLOB.available_ui_styles
- if(pickedui)
- UI_style = pickedui
- if (parent && parent.mob && parent.mob.hud_used)
- parent.mob.hud_used.update_ui_style(ui_style2icon(UI_style))
- if("pda_theme")
- var/pickedPDAStyle = input(user, "Choose your default PDA theme.", "Character Preference", pda_theme) as null|anything in GLOB.ntos_device_themes_default
- if(pickedPDAStyle)
- pda_theme = GLOB.ntos_device_themes_default[pickedPDAStyle]
- if("pda_color")
- var/pickedPDAColor = tgui_color_picker(user, "Choose your default Thinktronic Classic theme background color.", "Character Preference", pda_color)
- if(pickedPDAColor)
- pda_color = pickedPDAColor
- if ("see_balloon_alerts")
- var/pickedstyle = input(user, "Choose how you want balloon alerts displayed", "Balloon alert preference", BALLOON_ALERT_ALWAYS) as null|anything in list(BALLOON_ALERT_ALWAYS, BALLOON_ALERT_WITH_CHAT, BALLOON_ALERT_NEVER)
- if (!isnull(pickedstyle))
- see_balloon_alerts = pickedstyle
-
- else
- switch(href_list["preference"])
- if("publicity")
- if(unlock_content)
- toggles ^= PREFTOGGLE_MEMBER_PUBLIC
- if("gender")
- var/list/friendlyGenders = list("Male" = "male", "Female" = "female", "Other" = "plural")
- var/pickedGender = input(user, "Choose your gender.", "Character Preference", active_character.gender) as null|anything in friendlyGenders
- if(pickedGender && friendlyGenders[pickedGender] != active_character.gender)
- switch(friendlyGenders[pickedGender])
- if("plural")
- active_character.features["body_model"] = pick(MALE, FEMALE)
- else
- active_character.features["body_model"] = friendlyGenders[pickedGender]
- active_character.gender = friendlyGenders[pickedGender]
- if("body_model")
- active_character.features["body_model"] = active_character.features["body_model"] == MALE ? FEMALE : MALE
- if("hotkeys")
- toggles2 ^= PREFTOGGLE_2_HOTKEYS
- if(toggles2 & PREFTOGGLE_2_HOTKEYS)
- winset(user, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED] mainwindow.macro=default")
- else
- winset(user, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED] mainwindow.macro=old_default")
- if("action_buttons")
- toggles2 ^= PREFTOGGLE_2_LOCKED_BUTTONS
- if("tgui_fancy")
- toggles2 ^= PREFTOGGLE_2_FANCY_TGUI
- if("outline_enabled")
- toggles ^= PREFTOGGLE_OUTLINE_ENABLED
- if("outline_color")
- var/pickedOutlineColor = tgui_color_picker(user, "Choose your outline color.", "General Preference", outline_color)
- if(pickedOutlineColor)
- outline_color = pickedOutlineColor
- if("tgui_lock")
- toggles2 ^= PREFTOGGLE_2_LOCKED_TGUI
- if("winflash")
- toggles2 ^= PREFTOGGLE_2_WINDOW_FLASHING
- if("crewobj")
- toggles2 ^= PREFTOGGLE_2_WINDOW_FLASHING
-
- //here lies the badmins
- if("hear_adminhelps")
- user.client.toggleadminhelpsound()
- if("hear_adminalertsounds")
- user.client.toggleadminalertsound()
- if("hear_prayers")
- user.client.toggle_prayer_sound()
- if("announce_login")
- user.client.toggleannouncelogin()
- if("combohud_lighting")
- toggles ^= PREFTOGGLE_COMBOHUD_LIGHTING
- if("toggle_dead_chat")
- user.client.deadchat()
- if("toggle_radio_chatter")
- user.client.toggle_hear_radio()
- if("toggle_prayers")
- user.client.toggleprayers()
- if("toggle_deadmin_always")
- toggles ^= PREFTOGGLE_DEADMIN_ALWAYS
- if("toggle_deadmin_antag")
- toggles ^= PREFTOGGLE_DEADMIN_ANTAGONIST
- if("toggle_deadmin_head")
- toggles ^= PREFTOGGLE_DEADMIN_POSITION_HEAD
- if("toggle_deadmin_security")
- toggles ^= PREFTOGGLE_DEADMIN_POSITION_SECURITY
- if("toggle_deadmin_silicon")
- toggles ^= PREFTOGGLE_DEADMIN_POSITION_SILICON
-
-
- if("role_preferences")
- var/role_preference_type = href_list["role_preference_type"]
- var/role_preference_path = text2path(role_preference_type)
- var/datum/role_preference/role_pref = GLOB.role_preference_entries[role_preference_path]
- if(istype(role_pref))
- var/list/prefsource = role_pref.per_character ? active_character.role_preferences_character : role_preferences
- var/current = prefsource["[role_preference_type]"]
- if(isnum(current))
- prefsource["[role_preference_type]"] = !current
- else // not set, we assume it's on, so turn it off.
- prefsource["[role_preference_type]"] = FALSE
-
- if("role_preferences_enableall")
- var/role_preference_type = href_list["role_preference_type"]
- var/role_preference_path = text2path(role_preference_type)
- var/datum/role_preference/role_pref = GLOB.role_preference_entries[role_preference_path]
- if(istype(role_pref) && role_pref.per_character)
- for(var/datum/character_save/CS in character_saves)
- CS.role_preferences_character["[role_preference_type]"] = TRUE
-
- if("role_preferences_disableall")
- var/role_preference_type = href_list["role_preference_type"]
- var/role_preference_path = text2path(role_preference_type)
- var/datum/role_preference/role_pref = GLOB.role_preference_entries[role_preference_path]
- if(istype(role_pref) && role_pref.per_character)
- for(var/datum/character_save/CS in character_saves)
- CS.role_preferences_character["[role_preference_type]"] = FALSE
-
- if("name")
- active_character.be_random_name = !active_character.be_random_name
-
- if("all")
- active_character.be_random_body = !active_character.be_random_body
-
- if("hear_midis")
- toggles ^= PREFTOGGLE_SOUND_MIDI
-
- if("lobby_music")
- toggles ^= PREFTOGGLE_SOUND_LOBBY
- if((toggles & PREFTOGGLE_SOUND_LOBBY) && user.client && isnewplayer(user))
- user.client.playtitlemusic()
- else
- user.stop_sound_channel(CHANNEL_LOBBYMUSIC)
-
- if("soundtrack")
- toggles2 ^= PREFTOGGLE_2_SOUNDTRACK
- if((toggles2 & PREFTOGGLE_2_SOUNDTRACK))
- user.play_current_soundtrack()
- else
- user.stop_sound_channel(CHANNEL_SOUNDTRACK)
-
- if("ghost_ears")
- chat_toggles ^= CHAT_GHOSTEARS
-
- if("ghost_sight")
- chat_toggles ^= CHAT_GHOSTSIGHT
-
- if("ghost_whispers")
- chat_toggles ^= CHAT_GHOSTWHISPER
-
- if("ghost_radio")
- chat_toggles ^= CHAT_GHOSTRADIO
-
- if("ghost_pda")
- chat_toggles ^= CHAT_GHOSTPDA
-
- if("ghost_laws")
- chat_toggles ^= CHAT_GHOSTLAWS
-
- if("ghost_follow")
- chat_toggles ^= CHAT_GHOSTFOLLOWMINDLESS
-
- if("income_pings")
- chat_toggles ^= CHAT_BANKCARD
-
- if("pull_requests")
- chat_toggles ^= CHAT_PULLR
-
- if("tgui_input")
- toggles2 ^= PREFTOGGLE_2_TGUI_INPUT
-
- if("tgui_big_buttons")
- toggles2 ^= PREFTOGGLE_2_BIG_BUTTONS
-
- if("tgui_switched_buttons")
- toggles2 ^= PREFTOGGLE_2_SWITCHED_BUTTONS
-
- if("tgui_say")
- toggles2 ^= PREFTOGGLE_2_TGUI_SAY
- if(parent)
- if(parent.tgui_say)
- parent.tgui_say.close()
- parent.set_macros()
-
- if("tgui_say_light")
- toggles2 ^= PREFTOGGLE_2_SAY_LIGHT_THEME
- if(parent && parent.tgui_say) // change the theme
- parent.tgui_say.load()
-
- if("tgui_say_radio_prefix")
- toggles2 ^= PREFTOGGLE_2_SAY_SHOW_PREFIX
- if(parent && parent.tgui_say) // update the UI
- parent.tgui_say.load()
-
- if("parallaxup")
- parallax = WRAP(parallax + 1, PARALLAX_INSANE, PARALLAX_DISABLE + 1)
- if (parent && parent.mob && parent.mob.hud_used)
- parent.mob.hud_used.update_parallax_pref(parent.mob)
-
- if("parallaxdown")
- parallax = WRAP(parallax - 1, PARALLAX_INSANE, PARALLAX_DISABLE + 1)
- if (parent && parent.mob && parent.mob.hud_used)
- parent.mob.hud_used.update_parallax_pref(parent.mob)
-
- if("ambientocclusion")
- toggles2 ^= PREFTOGGLE_2_AMBIENT_OCCLUSION
- if(parent && parent.screen && parent.screen.len)
- var/atom/movable/screen/plane_master/game_world/game_pm = locate(/atom/movable/screen/plane_master/game_world) in parent.screen
- game_pm.backdrop(parent.mob)
- // Multiz shadow
- var/atom/movable/screen/plane_master/floor/floor_pm = locate(/atom/movable/screen/plane_master/floor) in parent.screen
- floor_pm.backdrop(parent.mob)
-
- if("auto_fit_viewport")
- toggles2 ^= PREFTOGGLE_2_AUTO_FIT_VIEWPORT
- if((toggles2 & PREFTOGGLE_2_AUTO_FIT_VIEWPORT) && parent)
- parent.fit_viewport()
-
- if("pixel_size")
- switch(pixel_size)
- if(PIXEL_SCALING_AUTO)
- pixel_size = PIXEL_SCALING_1X
- if(PIXEL_SCALING_1X)
- pixel_size = PIXEL_SCALING_1_2X
- if(PIXEL_SCALING_1_2X)
- pixel_size = PIXEL_SCALING_2X
- if(PIXEL_SCALING_2X)
- pixel_size = PIXEL_SCALING_3X
- if(PIXEL_SCALING_3X)
- pixel_size = PIXEL_SCALING_AUTO
- user.client.view_size.resetToDefault(getScreenSize(user)) //Fix our viewport size so it doesn't reset on change
-
- if("scaling_method")
- switch(scaling_method)
- if(SCALING_METHOD_NORMAL)
- scaling_method = SCALING_METHOD_DISTORT
- if(SCALING_METHOD_DISTORT)
- scaling_method = SCALING_METHOD_BLUR
- if(SCALING_METHOD_BLUR)
- scaling_method = SCALING_METHOD_NORMAL
- user.client.view_size.setZoomMode()
-
- if("save")
- save_preferences()
- active_character.save(user.client)
-
- if("load")
- load_from_database()
- load_characters()
-
- if("changeslot")
- var/numerical_slot = text2num(href_list["num"])
- var/datum/character_save/CS = character_saves[numerical_slot]
- if(CS && !CS.slot_locked)
- active_character = CS
- default_slot = numerical_slot
- // If its fresh, randomise & save it
- if(!CS.from_db)
- CS.randomise()
- CS.save(user.client)
-
- if("tab")
- if (href_list["tab"])
- current_tab = text2num(href_list["tab"])
-
- if("keybindings_menu")
- ShowKeybindings(user)
- return
-
- if("keybindings_capture")
- var/datum/keybinding/kb = GLOB.keybindings_by_name[href_list["keybinding"]]
- var/old_key = href_list["old_key"]
- CaptureKeybinding(user, kb, old_key)
- return
-
- if("keybindings_set")
- var/kb_name = href_list["keybinding"]
- if(!kb_name)
- user << browse(null, "window=capturekeypress")
- ShowKeybindings(user)
- return
-
- var/clear_key = text2num(href_list["clear_key"])
- var/old_key = href_list["old_key"]
-
- if(clear_key)
- if(old_key != "Unbound") // if it was already set
- key_bindings[old_key] -= kb_name
- key_bindings["Unbound"] += list(kb_name)
- save_preferences()
- user << browse(null, "window=capturekeypress")
- ShowKeybindings(user)
- return
-
- var/key = href_list["key"]
- var/numpad = text2num(href_list["numpad"])
- // TODO: Handle holding shift or alt down
- var/AltMod = text2num(href_list["alt"]) ? "Alt-" : ""
- var/CtrlMod = text2num(href_list["ctrl"]) ? "Ctrl-" : ""
- var/ShiftMod = text2num(href_list["shift"]) ? "Shift-" : ""
- // var/key_code = text2num(href_list["key_code"])
-
- var/new_key = uppertext(key)
-
- // This is a mapping from JS keys to Byond - ref: https://keycode.info/
- var/list/_kbMap = list(
- "INSERT" = "Insert", "HOME" = "Northwest", "PAGEUP" = "Northeast",
- "DEL" = "Delete", "END" = "Southwest", "PAGEDOWN" = "Southeast",
- "SPACEBAR" = "Space", "ALT" = "Alt", "SHIFT" = "Shift", "CONTROL" = "Ctrl"
- )
- new_key = _kbMap[new_key] ? _kbMap[new_key] : new_key
-
- if (numpad)
- new_key = "Numpad[new_key]"
-
- var/full_key = "[AltMod][CtrlMod][ShiftMod][new_key]"
- if(old_key && (old_key in key_bindings))
- key_bindings[old_key] -= kb_name
- key_bindings[full_key] += list(kb_name)
- key_bindings[full_key] = sort_list(key_bindings[full_key])
-
- save_preferences()
- user << browse(null, "window=capturekeypress")
- ShowKeybindings(user)
- return
-
- if("keybindings_done")
- user << browse(null, "window=keybindings")
-
- if("keybindings_reset")
- key_bindings = deep_copy_list(GLOB.keybinding_list_by_key)
- save_preferences()
- ShowKeybindings(user)
- return
-
- if("chat_on_map")
- toggles ^= PREFTOGGLE_RUNECHAT_GLOBAL
- if("see_chat_non_mob")
- toggles ^= PREFTOGGLE_RUNECHAT_NONMOBS
- if("see_rc_emotes")
- toggles ^= PREFTOGGLE_RUNECHAT_EMOTES
-
- ShowChoices(user)
- return 1
-
-/datum/preferences/proc/ask_for_custom_name(mob/user,name_id)
- var/namedata = GLOB.preferences_custom_names[name_id]
- if(!namedata)
- return
-
- var/raw_name = capped_input(user, "Choose your character's [namedata["qdesc"]]:","Character Preference")
- if(!raw_name)
- if(namedata["allow_null"])
- active_character.custom_names[name_id] = get_default_name(name_id)
- else
- return
- else
- var/sanitized_name = reject_bad_name(raw_name,namedata["allow_numbers"])
- if(!sanitized_name)
- to_chat(user, "Invalid name. Your name should be at least 2 and at most [MAX_NAME_LEN] characters long. It may only contain the characters A-Z, a-z,[namedata["allow_numbers"] ? ",0-9," : ""] -, ' and .")
- return
- else
- active_character.custom_names[name_id] = sanitized_name
-
-/// Handles adding and removing donator items from clients
-/datum/preferences/proc/handle_donator_items()
- var/datum/loadout_category/DLC = GLOB.loadout_categories["Donator"] // stands for donator loadout category but the other def for DLC works too xD
- if(!LAZYLEN(GLOB.patrons) || !CONFIG_GET(flag/donator_items)) // donator items are only accesibile by servers with a patreon
- return
- if(IS_PATRON(parent.ckey) || (parent in GLOB.admins))
- for(var/gear_id in DLC.gear)
- var/datum/gear/AG = DLC.gear[gear_id]
- if(AG.id in purchased_gear)
- continue
- purchased_gear += AG.id
- AG.purchase(parent)
- save_preferences()
- else if(length(purchased_gear) || length(active_character.equipped_gear))
- for(var/gear_id in DLC.gear)
- var/datum/gear/RG = DLC.gear[gear_id]
- active_character.equipped_gear -= RG.id
- purchased_gear -= RG.id
- save_preferences()
diff --git a/code/modules/client/preferences/README.md b/code/modules/client/preferences/README.md
new file mode 100644
index 0000000000000..9c40e105abd35
--- /dev/null
+++ b/code/modules/client/preferences/README.md
@@ -0,0 +1,604 @@
+# Preferences
+
+Credit to Mothblocks for writing the basis of this document and the preferences system.
+
+Ported and heavily altered by itsmeow to BeeStation.
+
+This does not contain all the information on specific values--you can find those as doc-comments in relevant paths, such as `/datum/preference`. Rather, this gives you an overview for creating _most_ preferences, and getting your foot in the door to create more advanced ones.
+
+## Reading Preferences
+
+Reading preferences is super simple:
+
+```dm
+prefs.read_player_preference(/datum/preference/toggle/sound_ship_ambience)
+```
+
+The above will read the ship ambiance toggle from player-prefs. If you want a character preference, you need to use `read_character_preference` instead. You can check the type of the preference datum by viewing its `preference_type` var.
+
+```dm
+prefs.read_character_preference(/datum/preference/name/real_name)
+```
+
+## Writing Preferences (outside the menu)
+
+You can alter a preference from code using the following code:
+
+```dm
+prefs.update_preference(/datum/preference/toggle/sound_ship_ambience, TRUE)
+```
+
+This would enable the ship ambience preference. This will also automatically queue a save.
+
+Altering an undatumized preference (e.g. one stored on the preferences datum itself, like job preferences) should always be followed by `prefs.mark_undatumized_dirty_player()` or `prefs.mark_undatumized_dirty_character()`, to ensure the preference saves. Datumized preferences will automatically save if update_preference is used.
+
+## Anatomy of a preference (A.K.A. how do I make one?)
+
+Most preferences consist of two parts:
+
+1. A `/datum/preference` type.
+2. A tgui representation in a TypeScript file.
+
+Every `/datum/preference` requires these three values be set:
+
+1. `category` - See [Categories](#Categories).
+2. `db_key` - The value which will be saved in the database. This will also be the identifier for tgui.
+3. `preference_type` - Whether or not this is a character specific preference (`PREFERENCE_CHARACTER`) or one that affects the player (`PREFERENCE_PLAYER`). As an example: hair color is `PREFERENCE_CHARACTER` while your UI settings are `PREFERENCE_PLAYER`, since they do not change between characters. This also affects which getter is used (get_player_preference or get_character_preference)
+
+For the tgui representation, most preferences will create a `.tsx` file in `tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/`. If your preference is a character preference, make a new file in `character_preferences`. Otherwise, put it in `game_preferences`. The filename does not matter, and this file can hold multiple relevant preferences if you would like.
+
+From here, you will want to write code resembling:
+
+```ts
+import { Feature } from "../base";
+
+export const db_key_here: Feature = {
+ name: "Preference Name Here",
+ component: Component,
+
+ // Necessary for game preferences, unused for others
+ category: "CATEGORY",
+
+ // Optional, only shown in game preferences
+ description: "This preference will blow your mind!",
+};
+```
+
+`T` and `Component` depend on the type of preference you're making. Here are all common examples...
+
+## Numeric preferences
+
+Examples include age and FPS.
+
+A numeric preference derives from `/datum/preference/numeric`.
+
+```dm
+/datum/preference/numeric/legs
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "legs"
+
+ minimum = 1
+ maximum = 8
+```
+
+You can optionally provide a `step` field. This value is 1 by default, meaning only integers are accepted.
+
+Your `.tsx` file would look like:
+
+```ts
+import { Feature, FeatureNumberInput } from "../base";
+
+export const legs: Feature = {
+ name: "Legs",
+ component: FeatureNumberInput,
+};
+```
+
+## Toggle preferences
+
+Examples include enabling tooltips.
+
+```dm
+/datum/preference/toggle/enable_breathing
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "enable_breathing"
+
+ // Optional, TRUE by default
+ default_value = FALSE
+```
+
+Your `.tsx` file would look like:
+
+```ts
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const enable_breathing: FeatureToggle = {
+ name: "Enable breathing",
+ component: CheckboxInput,
+};
+```
+
+## Choiced preferences
+
+A choiced preference is one where the only options are in a distinct few amount of choices. Examples include skin tone, shirt, and UI style.
+
+To create one, derive from `/datum/preference/choiced`.
+
+```dm
+/datum/preference/choiced/favorite_drink
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "favorite_drink"
+```
+
+Now we need to tell the game what the choices are. We do this by overriding `init_possible_values()`. This will return a list of possible options.
+
+```dm
+/datum/preference/choiced/favorite_drink/init_possible_values()
+ return list(
+ "Milk",
+ "Cola",
+ "Water",
+ )
+```
+
+Your `.tsx` file would then look like:
+
+```tsx
+import { FeatureChoiced, FeatureButtonedDropdownInput } from "../base";
+
+export const favorite_drink: FeatureChoiced = {
+ name: "Favorite drink",
+ component: FeatureButtonedDropdownInput,
+};
+```
+
+This will create a dropdown input for your preference, including buttons to cycle between options. Do note that if there are less than 4 options this will automatically be flattened into choice buttons.
+
+### Choiced preferences - Icons
+
+Choiced preferences can generate icons. This is how the clothing/species preferences work, for instance. However, if we just want a basic dropdown input with icons, it would look like this:
+
+```dm
+/datum/preference/choiced/favorite_drink
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "favorite_drink"
+ should_generate_icons = TRUE // NEW! This is necessary.
+
+// Instead of returning a flat list, this now returns an assoc list
+// of values to icons.
+/datum/preference/choiced/favorite_drink/init_possible_values()
+ return list(
+ "Milk" = icon('drinks.dmi', "milk"),
+ "Cola" = icon('drinks.dmi', "cola"),
+ "Water" = icon('drinks.dmi', "water"),
+ )
+```
+
+Then, change your `.tsx` file to look like:
+
+```tsx
+import { FeatureChoiced, FeatureIconnedDropdownInput } from "../base";
+
+export const favorite_drink: FeatureChoiced = {
+ name: "Favorite drink",
+ component: FeatureIconnedDropdownInput,
+};
+```
+
+### Choiced preferences - Display names
+
+Sometimes the values you want to save in code aren't the same as the ones you want to display. You can specify display names to change this.
+
+The only thing you will add is "compiled data".
+
+```dm
+/datum/preference/choiced/favorite_drink/compile_constant_data()
+ var/list/data = ..()
+
+ // An assoc list of values to display names
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = list(
+ "Milk" = "Delicious Milk",
+ "Cola" = "Crisp Cola",
+ "Water" = "Plain Ol' Water",
+ )
+
+ return data
+```
+
+Your `.tsx` file does not change. The UI will figure it out for you!
+
+## Color preferences
+
+These refer to colors, such as your OOC color. When read, these values will be given as 6 hex digits, _without_ the pound sign.
+
+```dm
+/datum/preference/color/eyeliner_color
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "eyeliner_color"
+```
+
+Your `.tsx` file would look like:
+
+```ts
+import { FeatureColorInput, Feature } from "../base";
+
+export const eyeliner_color: Feature = {
+ name: "Eyeliner color",
+ component: FeatureColorInput,
+};
+```
+
+## Name preferences
+
+These refer to an alternative name. Examples include AI names and backup human names.
+
+These exist in `code/modules/client/preferences/names.dm`.
+
+These do not need a `.ts` file, and will be created in the UI automatically.
+
+```dm
+/datum/preference/name/doctor
+ db_key = "doctor_name"
+
+ // The name on the UI
+ explanation = "Doctor name"
+
+ // This groups together with anything else with the same group
+ group = "medicine"
+
+ // Optional, if specified the UI will show this name actively
+ // when the player is a medical doctor.
+ relevant_job = /datum/job/medical_doctor
+```
+
+## Color Palettes
+
+This allows you to predefine color choices, and looks really nice. You can also lock it to specific colors or allow custom colors.
+
+`StandardizedPalette` props:
+
+- `choices`: A list of actual values this palette will give to DM.
+- `choices_to_hex`: A map of choice keys to their actual hex values, for display purposes. This is not needed if hex_values is true.
+- `displayNames`: A map of actual values to display names, for tooltips.
+- `onSetValue`: Called when a value is chosen.
+- `value`: The currently selected value.
+- `hex_values`: A boolean saying if the color provided is a hex color or a string (see: skin color, which is a string)
+- `allow_custom`: A boolean saying if you can select a custom color. Only works with hex values.
+- `featureId`: The feature ID of this entry.
+- `act`: The act() function of this entry.
+- `includeHex`: If the hex value should be shown on the tooltip / display name. Useful for custom color presets.
+
+```
+import { Feature, FeatureValueProps, StandardizedPalette } from '../base';
+
+const eyePresets = {
+ '#aaccff': 'Baby Blue',
+ '#0099bb': 'Blue-Green',
+};
+
+export const eye_color: Feature = {
+ name: 'Eye Color',
+ small_supplemental: false,
+ predictable: false,
+ component: (props: FeatureValueProps) => {
+ const { handleSetValue, value, featureId, act } = props;
+
+ return (
+
+ );
+ },
+};
+```
+
+## Attaching secondary preferences
+
+Some preferences are attached to other preferences, like hair color to hair styles. This is called a supplementary feature.
+
+To do this, first set its category to `PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES`:
+
+```dm
+/datum/preference/color_legacy/hair_color
+ db_key = "hair_color"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+```
+
+Then, on the parent feature, add to its constant data a SUPPLEMENTAL_FEATURE_KEY with the db_key of the supplemental:
+
+```dm
+/datum/preference/choiced/hairstyle/compile_constant_data()
+ var/list/data = ..()
+ data[SUPPLEMENTAL_FEATURE_KEY] = "hair_color"
+ return data
+```
+
+Now, configure its TGUI entry. `small_supplemental` dictates if it is placed in the top corner or at the bottom of the feature popup.
+
+`predictable` disables the TGUI-side prediction system that caches the value sent from the UI. This is important if the value sent is expected to be transformed in some way or updates atypically, such as with custom color palettes.
+
+```js
+export const hair_color: Feature = {
+ name: 'Hair Color',
+ small_supplemental: false,
+ predictable: false,
+ component: /* ... */,
+};
+```
+
+## Game Preferences
+
+Most of the documentation above covers character preferences. Game preferences have a few unique features as well, such as descriptions and subcategories.
+
+Here is an example:
+
+```js
+export const chat_radio: FeatureToggle = {
+ name: "Hear Radio",
+ category: "ADMIN",
+ subcategory: "Chat",
+ description: "Hear all radio messages while adminned.",
+ component: CheckboxInput,
+};
+```
+
+Category is which header it will fall under, and subcategory adds a subheader that will join with other entries in this category and subcategory. It will also show in search results. The description is shown on hover.
+
+## Making your preference do stuff
+
+There are a handful of procs preferences can use to act on their own:
+
+```dm
+/// Apply this preference onto the given client.
+/// Called when the preference_type == PREFERENCE_PLAYER.
+/datum/preference/proc/apply_to_client(client/client, value)
+
+/// Fired when the preference is updated.
+/// Calls apply_to_client by default, but can be overridden.
+/datum/preference/proc/apply_to_client_updated(client/client, value)
+
+/// Apply this preference onto the given human.
+/// Must be overriden by subtypes.
+/// Called when the preference_type == PREFERENCE_CHARACTER.
+/datum/preference/proc/apply_to_human(mob/living/carbon/human/target, value)
+```
+
+For example, `/datum/preference/numeric/age` contains:
+
+```dm
+/datum/preference/numeric/age/apply_to_human(mob/living/carbon/human/target, value)
+ target.age = value
+```
+
+If your preference is `PREFERENCE_CHARACTER`, it MUST override `apply_to_human`, even if just to immediately `return`.
+
+You can also read preferences directly with `preferences.read_character/player_preference(/datum/preference/type/here)`, which will return the stored value.
+
+## Categories
+
+Every preference needs to be in a `category`. These can be found in `code/__DEFINES/preferences.dm`.
+
+```dm
+/// These will be shown in the character sidebar, but at the bottom.
+#define PREFERENCE_CATEGORY_FEATURES "features"
+
+/// Any preferences that will show to the sides of the character in the setup menu.
+#define PREFERENCE_CATEGORY_CLOTHING "clothing"
+
+/// Preferences that will be put into the 3rd list, and are not contextual.
+#define PREFERENCE_CATEGORY_NON_CONTEXTUAL "non_contextual"
+
+/// Will be put under the game preferences window.
+#define PREFERENCE_CATEGORY_GAME_PREFERENCES "game_preferences"
+
+/// These will show in the list to the right of the character preview.
+#define PREFERENCE_CATEGORY_SECONDARY_FEATURES "secondary_features"
+
+/// These are preferences that are supplementary for main features,
+/// such as hair color being affixed to hair.
+#define PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES "supplemental_features"
+```
+
+![Preference categories for the main page](https://raw.githubusercontent.com/tgstation/documentation-assets/main/preferences/preference_categories.png)
+
+> SECONDARY_FEATURES or NON_CONTEXTUAL?
+
+Secondary features tend to be species specific. Non contextual features shouldn't change much from character to character.
+
+## Default values and randomization
+
+There are three procs to be aware of in regards to this topic:
+
+- `create_default_value()`. This is used when a value deserializes improperly or when a new character is created.
+- `create_informed_default_value(datum/preferences/preferences)` - Used for more complicated default values, like how names require the gender. Will call `create_default_value()` by default.
+- `create_random_value(datum/preferences/preferences)` - Explicitly used for random values, such as when a character is being randomized.
+
+`create_default_value()` in most preferences will create a random value. If this is a problem (like how default characters should always be human), you can override `create_default_value()`. By default (without overriding `create_random_value`), random values are just default values.
+
+## Advanced - Server data
+
+As previewed in [the display names implementation](#Choiced-preferences---Display-names), there exists a `compile_constant_data()` proc you can override.
+
+Compiled data is used wherever the server needs to give the client some value it can't figure out on its own. Skin tones use this to tell the client what colors they represent, for example.
+
+Compiled data is sent to the `serverData` field in the `FeatureValueProps`.
+
+## Advanced - Creating your own tgui component
+
+If you have good knowledge with tgui (especially TypeScript), you'll be able to create your own component to represent preferences.
+
+The `component` field in a feature accepts **any** component that accepts `FeatureValueProps`.
+
+This will give you the fields:
+
+```ts
+act: typeof sendAct,
+featureId: string,
+handleSetValue: (newValue: TSending) => void,
+serverData: TServerData | undefined,
+shrink?: boolean,
+value: TReceiving,
+```
+
+`act` is the same as the one you get from `useBackend`.
+
+`featureId` is the db_key of the feature.
+
+`handleSetValue` is a function that, when called, will tell the server the new value, as well as changing the value immediately locally.
+
+`serverData` is the [server data](#Advanced---Server-data), if it has been fetched yet (and exists).
+
+`shrink` is whether or not the UI should appear smaller. This is only used for supplementary features.
+
+`value` is the current value, could be predicted (meaning that the value was changed locally, but has not yet reached the server).
+
+For a basic example of how this can look, observe `CheckboxInput`:
+
+```tsx
+export const CheckboxInput = (
+ props: FeatureValueProps
+) => {
+ return (
+ {
+ props.handleSetValue(!props.value);
+ }}
+ />
+ );
+};
+```
+
+## Advanced - Middleware
+
+A `/datum/preference_middleware` is a way to inject your own data at specific points, as well as hijack actions.
+
+Middleware can hijack actions by specifying `action_delegations`:
+
+```dm
+/datum/preference_middleware/congratulations
+ action_delegations = list(
+ "congratulate_me" = PROC_REF(congratulate_me),
+ )
+
+/datum/preference_middleware/congratulations/proc/congratulate_me(list/params, mob/user)
+ to_chat(user, span_notice("Wow, you did a great job learning about middleware!"))
+
+ return TRUE
+```
+
+Middleware can inject its own data at several points, such as providing new UI assets, compiled data (used by middleware such as quirks to tell the client what quirks exist), etc. Look at `code/modules/client/preferences/middleware/_middleware.dm` for full information.
+
+---
+
+## Antagonists
+
+Role preferences are separate from antagonist datums and ban roles, but are connected. You can define a new role preference easily:
+
+```
+/datum/role_preference/antagonist/changeling
+ name = "Changeling"
+ description = "A highly intelligent alien predator that is capable of altering their \
+ shape to flawlessly resemble a human.\n\
+ Transform yourself or others into different identities, and buy from an \
+ arsenal of biological weaponry with the DNA you collect."
+ antag_datum = /datum/antagonist/changeling
+```
+
+Newlines (`\n`) are converted to Stack dividers in TGUI, making a horizontal line element. The antag_datum is used for checking bans / playtime.
+
+Defining a `preview_outfit` with an outfit typepath will make the icon preview a human with said outfit.
+
+You can also override `get_preview_icon()` to set a specific icon, look at other examples for more.
+
+Using this preference is a simple matter of checking `client.role_preference_enabled(/datum/role_preference/antagonist/changeling)`
+
+The parent type (`/datum/role_preference/antagonist`) determines what category it will show under. See `GLOB.role_preference_categories` for a list of categories.
+
+## Species
+
+Adding support for a species to the preference menu involves adding some proc overrides on the species datum (descriptions, traits, etc).
+
+Most importantly, override `get_species_description()` and `get_species_lore()`. Then, add any unique perks to `create_pref_unique_perks()`, or add them to the respective procs (see `get_species_perks()`) if they could apply to another species.
+
+You also need to set the plural_form for use in perk descriptions.
+
+Do note that many perks are automatically generated, so a perk may not actually be "unique". Unique perks often include roleplay elements (such as Asimov Superiority) rather than specific gameplay elements, since those can be generic (such as temperature resistance).
+
+A perk looks like this:
+
+```
+list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "radiation",
+ SPECIES_PERK_NAME = "Radiation Immune",
+ SPECIES_PERK_DESC = "[plural_form] are entirely immune to radiation.",
+))
+```
+
+The icon can be a tgfont icon (tg-iconname) or a fontawesome icon. Finding exact FA icons can be difficult, but searching the v5 index for free icons usually works. We use v5.9, but the index is only v5.15, so there may be some incorrect icons on the index.
+
+A perk can be `SPECIES_POSITIVE_PERK`, `SPECIES_NEGATIVE_PERK`, or `SPECIES_NEUTRAL_PERK`.
+
+Changing the preview icon can be done via overriding `/datum/species/proc/prepare_human_for_preview(mob/living/carbon/human/human)`, by changing various dna features. Make sure the result is not random, or it will change between game loads, which could be confusing.
+
+## Internals and Implementation details
+
+### Database Read/Write
+
+#### SSpreferences
+
+The preferences system reads and writes from the database, and otherwise has no other form of serialization. To reduce database traffic, preference writes are queued by the SSpreferences subsystem, which accepts preference datums by ckey and holds writes in a queue. Duplicate writes are not performed, so the maximum amount a preferences datum can write is every fire of this SS (approx 5 seconds).
+
+While preferences are in this queue, the TGUI is sent a status indicator with its queue status. When a write completes, the preferences menu updates a value stating if the write was successful or not, which is displayed on the UI, alongside a reason. This is shown in the title bar.
+
+Do note that closing the preference menu essentially forces an immediate save, bypassing the queue system. This is useful during disconnections, as the UI is closed before full disconnect, triggering a save, and the preferences subsystem will not process disconnected clients.
+
+#### Preference Holders
+
+Character and player preferences both have their own `/datum/preferences_holder`, as they have different database schemas and need their own logic. The preferences_holder controls writing and reading datumized preferences to/from the database and initializing default preferences when there is no database.
+
+##### Local Caching
+
+Alongside queuing writes to reduce traffic, all preference values are cached locally, as querying the database for every preference retrieval would be a huge overhead. This is done via the preferences holder, which stores an associative list of db_keys to preference values.
+
+##### Dirty Preferences
+
+To reduce the amount of data written when an update is performed, only values that are changed are written to the database. Previously, any time "Save Preferences" was pressed, all preferences values, regardless of if they were changed or not, were immediately written. This poses a huge waste of database bandwidth and introduces potential problems with changing every other preference accidentally if some type of error were to occur.
+
+Instead, a list of preference db_keys is maintained (`dirty_prefs`). When a value is updated, it adds its db_key to this list. Then, when a write is performed, this list forms the columns that will be updated, rather than simply including all of them. After the write, the list is cleared. This drastically reduces database use, during typical use.
+
+##### Value Serialization
+
+Game Preferences store all values as strings in the database, and so do many non-string character preferences. This means that before a write, all preferences are converted to strings and converted back when deserialized. This is performed in `/datum/preference/proc/deserialize` and ``/datum/preference/proc/serialize`. These procs are used both for reading/writing from the DB and reading/writing from the UI. For most things, strings are OK anyway, as many values are natively strings (choiced lists, colors, etc.), although numbers will do some basic number parsing.
+
+This does complicate some choiced lists, as it may not be ideal to store the display name in the database, but you want to show pretty names in the UI. In this case, serialize() fails to act as desired, since you will get the "ugly" name in the UI. This is when things like get_constant_data are used to map ugly names to pretty names for the UI. It is always best to prioritize the database over the UI, since the UI can adapt easily.
+
+Values inside the preference cache are always in their deserialized form, and are serialized ONLY when sent to the UI or database. This is because the value in the cache is what is directly returned when read in code.
+
+When a preference is written to the cache, it is always deserialized, as it is expected to come from the UI or database. This could be problematic if the deserializer is badly implemented and alters the already deserialized form of the preference.
+
+##### Undatumized system
+
+save_preferences() and save_character() include additional logic for undatumized preferences. This system is important for values that cannot be easily represented in the small units of datumized preferences (like job preferences), or are not actually preferences (like the last changelog value). In order to reduce overall database use while minimizing code clutter, undatumized preferences can be marked globally dirty, so that all undatumized prefs will write if any one changes. While this is not perfect, it reduces the code work put in for these less common preferences while minimzing unnecessary queries.
+
+Datumized and undatumized preferences use separate SQL queries for each write, so it is ideal to prevent writing one entirely if it can be done.
+
+These are marked dirty by the procs: `mark_undatumized_dirty_player` and `mark_undatumized_dirty_character`, which also queue writes to the database in SSpreferences. Essentially, if you want to change an undatumized preference in code, you should always call the matching proc here so that the change is actually saved. Because undatumized preferences have no proc wrappers around their values and are stored directly on the preference datum, this is the only way the preference system knows if they have changed.
+
+`ready_to_save_character()` and `ready_to_save_player()` will return if there are ANY dirty preferences (datumized or undatumized), if you want to know if any values have changed since last save.
diff --git a/code/modules/client/preferences/entries/character/age.dm b/code/modules/client/preferences/entries/character/age.dm
new file mode 100644
index 0000000000000..5d5d9bbf7a939
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/age.dm
@@ -0,0 +1,10 @@
+/datum/preference/numeric/age
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ db_key = "age"
+ preference_type = PREFERENCE_CHARACTER
+
+ minimum = AGE_MIN
+ maximum = AGE_MAX
+
+/datum/preference/numeric/age/apply_to_human(mob/living/carbon/human/target, value)
+ target.age = value
diff --git a/code/modules/client/preferences/entries/character/ai_core_display.dm b/code/modules/client/preferences/entries/character/ai_core_display.dm
new file mode 100644
index 0000000000000..c789ffde684d2
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/ai_core_display.dm
@@ -0,0 +1,25 @@
+/// What to show on the AI screen
+/datum/preference/choiced/ai_core_display
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "preferred_ai_core_display"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/ai_core_display/init_possible_values()
+ var/list/values = list()
+
+ values["Random"] = icon('icons/mob/ai.dmi', "ai-empty")
+
+ for (var/screen in GLOB.ai_core_display_screens - "Portrait" - "Random")
+ values[screen] = icon('icons/mob/ai.dmi', resolve_ai_icon_sync(screen))
+
+ return values
+
+/datum/preference/choiced/ai_core_display/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+
+ return istype(preferences.get_highest_priority_job(), /datum/job/ai)
+
+/datum/preference/choiced/ai_core_display/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/entries/character/body_model.dm b/code/modules/client/preferences/entries/character/body_model.dm
new file mode 100644
index 0000000000000..f5d3abb0ad81f
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/body_model.dm
@@ -0,0 +1,38 @@
+/datum/preference/choiced/body_model
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ priority = PREFERENCE_PRIORITY_BODY_MODEL
+ db_key = "body_model"
+ preference_type = PREFERENCE_CHARACTER
+
+/datum/preference/choiced/body_model/init_possible_values()
+ return list(MALE, FEMALE)
+
+/datum/preference/choiced/body_model/apply_to_human(mob/living/carbon/human/target, value)
+ if (target.gender != MALE && target.gender != FEMALE)
+ target.dna.features["body_model"] = value
+ else
+ target.dna.features["body_model"] = target.gender
+
+/datum/preference/choiced/body_model/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+ var/datum/species/species_type = preferences.read_character_preference(/datum/preference/choiced/species)
+ if(!initial(species_type.sexes))
+ return FALSE
+
+ var/gender = preferences.read_character_preference(/datum/preference/choiced/gender)
+ return gender != MALE && gender != FEMALE
+
+/datum/preference/choiced/body_size
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ db_key = "body_size"
+ preference_type = PREFERENCE_CHARACTER
+
+/datum/preference/choiced/body_size/init_possible_values()
+ return assoc_to_keys(GLOB.body_sizes)
+
+/datum/preference/choiced/body_size/create_default_value()
+ return "Normal"
+
+/datum/preference/choiced/body_size/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["body_size"] = value
diff --git a/code/modules/client/preferences/entries/character/clothing.dm b/code/modules/client/preferences/entries/character/clothing.dm
new file mode 100644
index 0000000000000..e4a03c1b21bd0
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/clothing.dm
@@ -0,0 +1,154 @@
+/proc/generate_values_for_underwear(list/accessory_list, list/icons, color)
+ var/icon/lower_half = icon('icons/effects/effects.dmi', "nothing")
+
+ for (var/icon in icons)
+ lower_half.Blend(icon('icons/mob/human_parts_greyscale.dmi', icon), ICON_OVERLAY)
+
+ var/list/values = list()
+
+ for (var/accessory_name in accessory_list)
+ var/icon/icon_with_socks = new(lower_half)
+
+ if (accessory_name != "Nude")
+ var/datum/sprite_accessory/accessory = accessory_list[accessory_name]
+
+ var/icon/accessory_icon = icon('icons/mob/clothing/underwear.dmi', accessory.icon_state)
+ if (color && !accessory.use_static)
+ accessory_icon.Blend(color, ICON_MULTIPLY)
+ icon_with_socks.Blend(accessory_icon, ICON_OVERLAY)
+
+ icon_with_socks.Crop(10, 1, 22, 13)
+ icon_with_socks.Scale(32, 32)
+
+ values[accessory_name] = icon_with_socks
+
+ return values
+
+/// Backpack preference
+/datum/preference/choiced/backpack
+ db_key = "backbag"
+ preference_type = PREFERENCE_CHARACTER
+ main_feature_name = "Backpack"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/backpack/init_possible_values()
+ var/list/values = list()
+
+ values[GBACKPACK] = /obj/item/storage/backpack
+ values[GSATCHEL] = /obj/item/storage/backpack/satchel
+ values[LSATCHEL] = /obj/item/storage/backpack/satchel/leather
+ values[GDUFFELBAG] = /obj/item/storage/backpack/duffelbag
+
+ // In a perfect world, these would be your department's backpack.
+ // However, this doesn't factor in assistants, or no high slot, and would
+ // also increase the spritesheet size a lot.
+ // I play medical doctor, and so medical doctor you get.
+ values[DBACKPACK] = /obj/item/storage/backpack/medic
+ values[DSATCHEL] = /obj/item/storage/backpack/satchel/med
+ values[DDUFFELBAG] = /obj/item/storage/backpack/duffelbag/med
+
+ return values
+
+/datum/preference/choiced/backpack/apply_to_human(mob/living/carbon/human/target, value)
+ target.backbag = value
+
+/// Jumpsuit preference
+/datum/preference/choiced/jumpsuit_style
+ db_key = "jumpsuit_style"
+ preference_type = PREFERENCE_CHARACTER
+ main_feature_name = "Jumpsuit"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/jumpsuit_style/init_possible_values()
+ var/list/values = list()
+
+ values[PREF_SUIT] = /obj/item/clothing/under/color/grey
+ values[PREF_SKIRT] = /obj/item/clothing/under/color/jumpskirt/grey
+
+ return values
+
+/datum/preference/choiced/jumpsuit_style/apply_to_human(mob/living/carbon/human/target, value)
+ target.jumpsuit_style = value
+
+/// Socks preference
+/datum/preference/choiced/socks
+ db_key = "socks"
+ preference_type = PREFERENCE_CHARACTER
+ main_feature_name = "Socks"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+ preference_spritesheet = PREFERENCE_SHEET_LARGE
+
+/datum/preference/choiced/socks/init_possible_values()
+ return generate_values_for_underwear(GLOB.socks_list, list("human_r_leg", "human_l_leg"))
+
+/datum/preference/choiced/socks/apply_to_human(mob/living/carbon/human/target, value)
+ target.socks = value
+
+/// Undershirt preference
+/datum/preference/choiced/undershirt
+ db_key = "undershirt"
+ preference_type = PREFERENCE_CHARACTER
+ main_feature_name = "Undershirt"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+ preference_spritesheet = PREFERENCE_SHEET_LARGE
+
+/datum/preference/choiced/undershirt/init_possible_values()
+ var/icon/body = icon('icons/mob/human_parts_greyscale.dmi', "human_r_leg")
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_l_leg"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_r_arm"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_l_arm"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_r_hand"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_l_hand"), ICON_OVERLAY)
+ body.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_chest_m"), ICON_OVERLAY)
+
+ var/list/values = list()
+
+ for (var/accessory_name in GLOB.undershirt_list)
+ var/icon/icon_with_undershirt = icon(body)
+
+ if (accessory_name != "Nude")
+ var/datum/sprite_accessory/accessory = GLOB.undershirt_list[accessory_name]
+ icon_with_undershirt.Blend(icon('icons/mob/clothing/underwear.dmi', accessory.icon_state), ICON_OVERLAY)
+
+ icon_with_undershirt.Crop(9, 9, 23, 23)
+ icon_with_undershirt.Scale(32, 32)
+ values[accessory_name] = icon_with_undershirt
+
+ return values
+
+/datum/preference/choiced/undershirt/apply_to_human(mob/living/carbon/human/target, value)
+ target.undershirt = value
+
+/// Underwear preference
+/datum/preference/choiced/underwear
+ db_key = "underwear"
+ preference_type = PREFERENCE_CHARACTER
+ main_feature_name = "Underwear"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+ preference_spritesheet = PREFERENCE_SHEET_LARGE
+
+/datum/preference/choiced/underwear/init_possible_values()
+ return generate_values_for_underwear(GLOB.underwear_list, list("human_chest_m", "human_r_leg", "human_l_leg"), COLOR_ALMOST_BLACK)
+
+/datum/preference/choiced/underwear/apply_to_human(mob/living/carbon/human/target, value)
+ target.underwear = value
+
+/datum/preference/choiced/underwear/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+
+ var/species_type = preferences.read_character_preference(/datum/preference/choiced/species)
+ var/datum/species/species = new species_type
+ return !(NO_UNDERWEAR in species.species_traits)
+
+/datum/preference/choiced/underwear/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "underwear_color"
+
+ return data
diff --git a/code/modules/client/preferences/entries/character/gender.dm b/code/modules/client/preferences/entries/character/gender.dm
new file mode 100644
index 0000000000000..25cc76d2e5474
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/gender.dm
@@ -0,0 +1,13 @@
+/// Gender preference
+/datum/preference/choiced/gender
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "gender"
+ priority = PREFERENCE_PRIORITY_GENDER
+
+/datum/preference/choiced/gender/init_possible_values()
+ return list(MALE, FEMALE, PLURAL)
+
+/datum/preference/choiced/gender/apply_to_human(mob/living/carbon/human/target, value)
+ if(!target.dna.species.sexes)
+ value = PLURAL //disregard gender preferences on this species
+ target.gender = value
diff --git a/code/modules/client/preferences/entries/character/names.dm b/code/modules/client/preferences/entries/character/names.dm
new file mode 100644
index 0000000000000..1d7b3c70821d4
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/names.dm
@@ -0,0 +1,160 @@
+/// A preference for a name. Used not just for normal names, but also for clown names, etc.
+/datum/preference/name
+ category = "names"
+ priority = PREFERENCE_PRIORITY_NAMES
+ preference_type = PREFERENCE_CHARACTER
+ abstract_type = /datum/preference/name
+
+ /// The display name when showing on the "other names" panel
+ var/explanation
+
+ /// These will be grouped together on the preferences menu
+ var/group
+
+ /// Whether or not to allow numbers in the person's name
+ var/allow_numbers = FALSE
+
+ /// If the highest priority job matches this, will prioritize this name in the UI
+ var/relevant_job
+
+/datum/preference/name/apply_to_human(mob/living/carbon/human/target, value)
+ // Only real_name applies directly, everything else is applied by something else
+ return
+
+/datum/preference/name/deserialize(input, datum/preferences/preferences)
+ return reject_bad_name("[input]", allow_numbers)
+
+/datum/preference/name/serialize(input)
+ // `is_valid` should always be run before `serialize`, so it should not
+ // be possible for this to return `null`.
+ return reject_bad_name(input, allow_numbers)
+
+/datum/preference/name/is_valid(value)
+ return istext(value) && !isnull(reject_bad_name(value, allow_numbers))
+
+/// A character's real name
+/datum/preference/name/real_name
+ explanation = "Name"
+ // The `_` makes it first in ABC order.
+ group = "_real_name"
+ db_key = "real_name"
+ informed = TRUE
+ // Used in serialize and is_valid
+ allow_numbers = TRUE
+
+/datum/preference/name/real_name/apply_to_human(mob/living/carbon/human/target, value)
+ target.real_name = value
+ target.name = value
+
+/datum/preference/name/real_name/create_informed_default_value(datum/preferences/preferences)
+ var/species_type = preferences.read_character_preference(/datum/preference/choiced/species)
+ var/gender = preferences.read_character_preference(/datum/preference/choiced/gender)
+
+ var/datum/species/species = new species_type
+
+ return species.random_name(gender, unique = TRUE)
+
+/datum/preference/name/real_name/deserialize(input, datum/preferences/preferences)
+ var/datum/species/selected_species = preferences.read_character_preference(/datum/preference/choiced/species)
+ input = reject_bad_name(input, initial(selected_species.allow_numbers_in_name))
+ if (!input)
+ return input
+
+ if (CONFIG_GET(flag/humans_need_surnames) && selected_species == /datum/species/human)
+ var/first_space = findtext(input, " ")
+ if(!first_space) //we need a surname
+ input += " [pick(GLOB.last_names)]"
+ else if(first_space == length(input))
+ input += "[pick(GLOB.last_names)]"
+ return input
+
+/// The name for a backup human, when nonhumans are made into head of staff
+/datum/preference/name/backup_human
+ explanation = "Backup human name"
+ group = "backup_human"
+ db_key = "human_name"
+ informed = TRUE
+
+/datum/preference/name/backup_human/create_informed_default_value(datum/preferences/preferences)
+ var/gender = preferences.read_character_preference(/datum/preference/choiced/gender)
+
+ return random_unique_name(gender)
+
+/datum/preference/name/clown
+ db_key = "clown_name"
+
+ explanation = "Clown name"
+ group = "fun"
+ relevant_job = /datum/job/clown
+
+/datum/preference/name/clown/create_default_value()
+ return pick(GLOB.clown_names)
+
+/datum/preference/name/mime
+ db_key = "mime_name"
+
+ explanation = "Mime name"
+ group = "fun"
+ relevant_job = /datum/job/mime
+
+/datum/preference/name/mime/create_default_value()
+ return pick(GLOB.mime_names)
+
+/datum/preference/name/cyborg
+ db_key = "cyborg_name"
+
+ allow_numbers = TRUE
+ can_randomize = FALSE
+
+ explanation = "Cyborg name"
+ group = "silicons"
+ relevant_job = /datum/job/cyborg
+
+/datum/preference/name/cyborg/create_default_value()
+ return DEFAULT_CYBORG_NAME
+
+/datum/preference/name/ai
+ db_key = "ai_name"
+
+ allow_numbers = TRUE
+ explanation = "AI name"
+ group = "silicons"
+ relevant_job = /datum/job/ai
+
+/datum/preference/name/ai/create_default_value()
+ return pick(GLOB.ai_names)
+
+/datum/preference/name/religion
+ db_key = "religion_name"
+
+ allow_numbers = TRUE
+
+ explanation = "Religion name"
+ group = "religion"
+
+/datum/preference/name/religion/create_default_value()
+ return DEFAULT_RELIGION
+
+/datum/preference/name/deity
+ db_key = "deity_name"
+
+ allow_numbers = TRUE
+ can_randomize = FALSE
+
+ explanation = "Deity name"
+ group = "religion"
+
+/datum/preference/name/deity/create_default_value()
+ return DEFAULT_DEITY
+
+/datum/preference/name/bible
+ db_key = "bible_name"
+
+ allow_numbers = TRUE
+ can_randomize = FALSE
+
+ explanation = "Bible name"
+ group = "religion"
+
+/datum/preference/name/bible/create_default_value()
+ return DEFAULT_BIBLE
diff --git a/code/modules/client/preferences/entries/character/pda.dm b/code/modules/client/preferences/entries/character/pda.dm
new file mode 100644
index 0000000000000..a5cff7134072d
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/pda.dm
@@ -0,0 +1,45 @@
+/// The visual style of a PDA
+/datum/preference/choiced/pda_theme
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ db_key = "pda_theme"
+ preference_type = PREFERENCE_CHARACTER
+
+/datum/preference/choiced/pda_theme/compile_ui_data(mob/user, value)
+ return value // The default behavior is to serialize. Don't do that.
+
+/datum/preference/choiced/pda_theme/deserialize(input, datum/preferences/preferences)
+ for(var/key in GLOB.ntos_device_themes_default)
+ if(GLOB.ntos_device_themes_default[key] == input || key == input)
+ return key
+ return "NtOS Default"
+
+/datum/preference/choiced/pda_theme/serialize(input)
+ for(var/key in GLOB.ntos_device_themes_default)
+ var/value = GLOB.ntos_device_themes_default[key]
+ if(value == input || key == input)
+ return value
+ return GLOB.ntos_device_themes_default[sanitize_inlist(input, get_choices(), "NtOS Default")]
+
+/datum/preference/choiced/pda_theme/create_default_value()
+ return "NtOS Default"
+
+/datum/preference/choiced/pda_theme/init_possible_values()
+ return assoc_to_keys(GLOB.ntos_device_themes_default)
+
+/datum/preference/choiced/pda_theme/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/// The color of a PDA with Thinktronic Classic
+/datum/preference/color/pda_classic_color
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ db_key = "pda_classic_color"
+ preference_type = PREFERENCE_CHARACTER
+
+/datum/preference/color/pda_classic_color/create_default_value()
+ return COLOR_OLIVE
+
+/datum/preference/color/pda_classic_color/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && preferences.read_character_preference(/datum/preference/choiced/pda_theme) == "Thinktronic Classic"
+
+/datum/preference/color/pda_classic_color/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/entries/character/random.dm b/code/modules/client/preferences/entries/character/random.dm
new file mode 100644
index 0000000000000..72c098056d268
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/random.dm
@@ -0,0 +1,37 @@
+/datum/preference/choiced/random_body
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ db_key = "body_is_always_random"
+ preference_type = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/choiced/random_body/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/choiced/random_body/init_possible_values()
+ return list(
+ RANDOM_ANTAG_ONLY,
+ RANDOM_DISABLED,
+ RANDOM_ENABLED,
+ )
+
+/datum/preference/choiced/random_body/create_default_value()
+ return RANDOM_DISABLED
+
+/datum/preference/choiced/random_name
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ db_key = "name_is_always_random"
+ preference_type = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/choiced/random_name/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/choiced/random_name/init_possible_values()
+ return list(
+ RANDOM_ANTAG_ONLY,
+ RANDOM_DISABLED,
+ RANDOM_ENABLED,
+ )
+
+/datum/preference/choiced/random_name/create_default_value()
+ return RANDOM_DISABLED
diff --git a/code/modules/client/preferences/entries/character/security_department.dm b/code/modules/client/preferences/entries/character/security_department.dm
new file mode 100644
index 0000000000000..489356f3a0207
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/security_department.dm
@@ -0,0 +1,21 @@
+/// Which department to put security officers in, when the config is enabled
+/datum/preference/choiced/security_department
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ can_randomize = FALSE
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "preferred_security_department"
+
+// This is what that #warn wants you to remove :)
+/datum/preference/choiced/security_department/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.security_depts_prefs))
+ return SEC_DEPT_NONE
+ return ..()
+
+/datum/preference/choiced/security_department/init_possible_values()
+ return GLOB.security_depts_prefs
+
+/datum/preference/choiced/security_department/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/choiced/security_department/create_default_value()
+ return SEC_DEPT_NONE
diff --git a/code/modules/client/preferences/entries/character/skin_tone.dm b/code/modules/client/preferences/entries/character/skin_tone.dm
new file mode 100644
index 0000000000000..d11246d463854
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/skin_tone.dm
@@ -0,0 +1,36 @@
+/datum/preference/choiced/skin_tone
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "skin_tone"
+
+/datum/preference/choiced/skin_tone/init_possible_values()
+ return GLOB.skin_tones
+
+/datum/preference/choiced/skin_tone/compile_constant_data()
+ var/list/data = ..()
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = GLOB.skin_tone_names
+
+ var/list/to_hex = list()
+ for (var/choice in get_choices())
+ var/hex_value = skintone2hex(choice, include_tag = TRUE)
+ var/list/hsl = rgb2num(hex_value, COLORSPACE_HSL)
+
+ to_hex[choice] = list(
+ "lightness" = hsl[3],
+ "value" = hex_value,
+ )
+
+ data["to_hex"] = to_hex
+
+ return data
+
+/datum/preference/choiced/skin_tone/apply_to_human(mob/living/carbon/human/target, value)
+ target.skin_tone = value
+
+/datum/preference/choiced/skin_tone/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+
+ var/datum/species/species_type = preferences.read_character_preference(/datum/preference/choiced/species)
+ return initial(species_type.use_skintones)
diff --git a/code/modules/client/preferences/entries/character/species.dm b/code/modules/client/preferences/entries/character/species.dm
new file mode 100644
index 0000000000000..897c9961b55ee
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species.dm
@@ -0,0 +1,53 @@
+/// Species preference
+/datum/preference/choiced/species
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "species"
+ priority = PREFERENCE_PRIORITY_SPECIES
+ randomize_by_default = FALSE
+
+/datum/preference/choiced/species/deserialize(input, datum/preferences/preferences)
+ return GLOB.species_list[sanitize_inlist(input, get_acceptable_species(), get_fallback_species_id())]
+
+/datum/preference/choiced/species/serialize(input)
+ var/datum/species/species = input
+ return initial(species.id)
+
+/datum/preference/choiced/species/create_default_value()
+ return /datum/species/human
+
+/datum/preference/choiced/species/create_random_value(datum/preferences/preferences)
+ return pick(get_choices())
+
+/datum/preference/choiced/species/init_possible_values()
+ var/list/values = list()
+
+ for (var/species_id in get_selectable_species())
+ values += GLOB.species_list[species_id]
+
+ return values
+
+/datum/preference/choiced/species/apply_to_human(mob/living/carbon/human/target, value)
+ target.set_species(value, icon_update = FALSE, pref_load = TRUE)
+
+/datum/preference/choiced/species/compile_constant_data()
+ var/list/data = list()
+
+ for (var/species_id in get_acceptable_species())
+ var/species_type = GLOB.species_list[species_id]
+ var/datum/species/species = new species_type()
+
+ data[species_id] = list()
+ data[species_id]["name"] = species.name
+ data[species_id]["desc"] = species.get_species_description()
+ data[species_id]["lore"] = species.get_species_lore()
+ data[species_id]["icon"] = sanitize_css_class_name(species.name)
+ data[species_id]["use_skintones"] = species.use_skintones
+ data[species_id]["sexes"] = species.sexes
+ data[species_id]["enabled_features"] = species.get_features()
+ data[species_id]["perks"] = species.get_species_perks()
+ data[species_id]["diet"] = species.get_species_diet()
+ data[species_id]["selectable"] = species.check_roundstart_eligible()
+
+ qdel(species)
+
+ return data
diff --git a/code/modules/client/preferences/entries/character/species_features/apid.dm b/code/modules/client/preferences/entries/character/species_features/apid.dm
new file mode 100644
index 0000000000000..be6c91bfd3726
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/apid.dm
@@ -0,0 +1,88 @@
+/datum/preference/choiced/apid_stripes
+ db_key = "feature_apid_stripes"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Stripe Pattern"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "apid_stripes"
+
+/datum/preference/choiced/apid_stripes/init_possible_values()
+ var/list/values = list()
+
+ for (var/stripe_name in GLOB.apid_stripes_list)
+ var/datum/sprite_accessory/stripe = GLOB.apid_stripes_list[stripe_name]
+
+ var/icon/icon_with_stripes = icon('icons/mob/species/apid/bodyparts.dmi', "apid_chest_m", dir = SOUTH)
+ if (stripe.icon_state != "none")
+ var/icon/stripes_icon = icon(stripe.icon, "m_apid_stripes_[stripe.icon_state]_ADJ", dir = SOUTH)
+ stripes_icon.Blend(COLOR_YELLOW, ICON_MULTIPLY)
+ icon_with_stripes.Blend(stripes_icon, ICON_OVERLAY)
+
+ icon_with_stripes.Crop(10, 8, 22, 23)
+ icon_with_stripes.Scale(26, 32)
+ icon_with_stripes.Crop(-2, 1, 29, 32)
+
+ values[stripe.name] = icon_with_stripes
+
+ return values
+
+/datum/preference/choiced/apid_stripes/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["apid_stripes"] = value
+
+/datum/preference/choiced/apid_antenna
+ db_key = "feature_apid_antenna"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Antennae Style"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "apid_antenna"
+
+/datum/preference/choiced/apid_antenna/init_possible_values()
+ var/list/values = list()
+
+ for (var/antenna_name in GLOB.apid_antenna_list)
+ var/datum/sprite_accessory/antenna = GLOB.apid_antenna_list[antenna_name]
+
+ var/icon/icon_with_antennae = icon('icons/mob/species/apid/bodyparts.dmi', "apid_head_m", dir = SOUTH)
+ if (antenna.icon_state != "none")
+ var/icon/antenna_icon = icon(antenna.icon, "m_apid_antenna_[antenna.icon_state]_ADJ", dir = SOUTH)
+ antenna_icon.Blend(COLOR_YELLOW, ICON_MULTIPLY)
+ icon_with_antennae.Blend(antenna_icon, ICON_OVERLAY)
+ icon_with_antennae.Scale(64, 64)
+ icon_with_antennae.Crop(15, 64, 15 + 31, 64 - 31)
+
+ values[antenna.name] = icon_with_antennae
+
+ return values
+
+/datum/preference/choiced/apid_antenna/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["apid_antenna"] = value
+
+/datum/preference/choiced/apid_headstripes
+ db_key = "feature_apid_headstripes"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Headstripe Pattern"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "apid_headstripes"
+
+/datum/preference/choiced/apid_headstripes/init_possible_values()
+ var/list/values = list()
+
+ for (var/headstripe_name in GLOB.apid_headstripes_list)
+ var/datum/sprite_accessory/headstripe = GLOB.apid_headstripes_list[headstripe_name]
+
+ var/icon/icon_with_headstripes = icon('icons/mob/species/apid/bodyparts.dmi', "apid_head_m", dir = SOUTH)
+ if (headstripe.icon_state != "none")
+ var/icon/headstripes_icon = icon(headstripe.icon, "m_apid_headstripes_[headstripe.icon_state]_ADJ", dir = SOUTH)
+ headstripes_icon.Blend(COLOR_YELLOW, ICON_MULTIPLY)
+ icon_with_headstripes.Blend(headstripes_icon, ICON_OVERLAY)
+ icon_with_headstripes.Scale(64, 64)
+ icon_with_headstripes.Crop(15, 64, 15 + 31, 64 - 31)
+
+ values[headstripe.name] = icon_with_headstripes
+
+ return values
+
+/datum/preference/choiced/apid_headstripes/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["apid_headstripes"] = value
diff --git a/code/modules/client/preferences/entries/character/species_features/basic.dm b/code/modules/client/preferences/entries/character/species_features/basic.dm
new file mode 100644
index 0000000000000..37ee5243b747f
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/basic.dm
@@ -0,0 +1,182 @@
+/proc/generate_possible_values_for_sprite_accessories_on_head(accessories)
+ var/list/values = possible_values_for_sprite_accessory_list(accessories)
+
+ var/icon/head_icon = icon('icons/mob/human_parts_greyscale.dmi', "human_head_m")
+ head_icon.Blend(skintone2hex("caucasian1", include_tag = TRUE), ICON_MULTIPLY)
+
+ for (var/name in values)
+ var/datum/sprite_accessory/accessory = accessories[name]
+ if (accessory == null || accessory.icon_state == null)
+ continue
+
+ var/icon/final_icon = new(head_icon)
+
+ var/icon/beard_icon = values[name]
+ beard_icon.Blend("#42250a", ICON_MULTIPLY)
+ final_icon.Blend(beard_icon, ICON_OVERLAY)
+
+ final_icon.Crop(10, 19, 22, 31)
+ final_icon.Scale(32, 32)
+
+ values[name] = final_icon
+
+ return values
+
+/datum/preference/color_legacy/eye_color
+ db_key = "eye_color"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_species_trait = EYECOLOR
+ priority = PREFERENCE_PRIORITY_EYE_COLOR
+
+/datum/preference/color_legacy/eye_color/apply_to_human(mob/living/carbon/human/target, value)
+ if(isipc(target))
+ return
+ target.eye_color = value
+
+ var/obj/item/organ/eyes/eyes_organ = target.getorgan(/obj/item/organ/eyes)
+ if (istype(eyes_organ))
+ if (!initial(eyes_organ.eye_color))
+ eyes_organ.eye_color = value
+ eyes_organ.old_eye_color = value
+
+/datum/preference/color_legacy/eye_color/create_default_value()
+ return random_eye_color()
+
+/datum/preference/choiced/facial_hairstyle
+ db_key = "facial_style_name"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Facial Hair"
+ should_generate_icons = TRUE
+ relevant_species_trait = FACEHAIR
+ preference_spritesheet = PREFERENCE_SHEET_LARGE
+
+/datum/preference/choiced/facial_hairstyle/init_possible_values()
+ return generate_possible_values_for_sprite_accessories_on_head(GLOB.facial_hair_styles_list)
+
+/datum/preference/choiced/facial_hairstyle/apply_to_human(mob/living/carbon/human/target, value)
+ target.facial_hair_style = value
+
+/datum/preference/choiced/facial_hairstyle/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "facial_hair_color"
+
+ return data
+
+/datum/preference/color_legacy/facial_hair_color
+ db_key = "facial_hair_color"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+ relevant_species_trait = FACEHAIR
+
+/datum/preference/color_legacy/facial_hair_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.facial_hair_color = value
+
+/datum/preference/color_legacy/hair_color
+ db_key = "hair_color"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+ relevant_species_trait = HAIR
+
+/datum/preference/color_legacy/hair_color/apply_to_human(mob/living/carbon/human/target, value)
+ if(isipc(target))
+ return
+ target.hair_color = value
+
+/datum/preference/choiced/hairstyle
+ db_key = "hair_style_name"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Hair Style"
+ should_generate_icons = TRUE
+ relevant_species_trait = HAIR
+ preference_spritesheet = PREFERENCE_SHEET_HUGE
+
+/datum/preference/choiced/hairstyle/init_possible_values()
+ return generate_possible_values_for_sprite_accessories_on_head(GLOB.hair_styles_list)
+
+/datum/preference/choiced/hairstyle/apply_to_human(mob/living/carbon/human/target, value)
+ target.hair_style = value
+
+/datum/preference/choiced/hairstyle/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "hair_color"
+
+ return data
+
+/datum/preference/choiced/gradient_style
+ db_key = "gradient_style"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Gradient Style"
+ should_generate_icons = TRUE
+ relevant_species_trait = HAIR
+
+/datum/preference/choiced/gradient_style/init_possible_values()
+ var/list/values = possible_values_for_sprite_accessory_list(GLOB.hair_gradients_list)
+
+ var/list/body_parts = list(
+ BODY_ZONE_HEAD,
+ BODY_ZONE_CHEST,
+ BODY_ZONE_L_ARM,
+ BODY_ZONE_R_ARM,
+ BODY_ZONE_PRECISE_L_HAND,
+ BODY_ZONE_PRECISE_R_HAND,
+ BODY_ZONE_L_LEG,
+ BODY_ZONE_R_LEG,
+ )
+ var/icon/body_icon = icon('icons/effects/effects.dmi', "nothing")
+ for (var/body_part in body_parts)
+ var/gender = body_part == BODY_ZONE_CHEST || body_part == BODY_ZONE_HEAD ? "_m" : ""
+ body_icon.Blend(icon('icons/mob/human_parts_greyscale.dmi', "human_[body_part][gender]", dir = NORTH), ICON_OVERLAY)
+ body_icon.Blend(skintone2hex("caucasian1", include_tag = TRUE), ICON_MULTIPLY)
+ var/icon/jumpsuit_icon = icon('icons/mob/clothing/uniform.dmi', "jumpsuit", dir = NORTH)
+ jumpsuit_icon.Blend("#b3b3b3", ICON_MULTIPLY)
+ body_icon.Blend(jumpsuit_icon, ICON_OVERLAY)
+
+ var/datum/sprite_accessory/hair_accessory = GLOB.hair_styles_list["Very Long Hair 2"]
+ var/icon/hair_icon = icon(hair_accessory.icon, hair_accessory.icon_state, dir = NORTH)
+ hair_icon.Blend("#080501", ICON_MULTIPLY)
+
+ for (var/name in values)
+ var/datum/sprite_accessory/accessory = GLOB.hair_gradients_list[name]
+ if (accessory == null)
+ if(accessory.icon_state == null || accessory.icon_state == "none")
+ values[name] = icon('icons/mob/landmarks.dmi', "x")
+ continue
+
+ var/icon/final_icon = new(body_icon)
+ var/icon/base_hair_icon = new(hair_icon)
+ var/icon/gradient_hair_icon = icon(hair_accessory.icon, hair_accessory.icon_state, dir = NORTH)
+
+ var/icon/gradient_icon = values[name]
+ gradient_icon.Blend(gradient_hair_icon, ICON_ADD)
+ gradient_icon.Blend("#42250a", ICON_MULTIPLY)
+ base_hair_icon.Blend(gradient_icon, ICON_OVERLAY)
+
+ final_icon.Blend(base_hair_icon, ICON_OVERLAY)
+ values[name] = final_icon
+
+ return values
+
+/datum/preference/choiced/gradient_style/apply_to_human(mob/living/carbon/human/target, value)
+ target.gradient_style = value
+
+/datum/preference/choiced/gradient_style/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "gradient_color"
+
+ return data
+
+/datum/preference/color_legacy/gradient_color
+ db_key = "gradient_color"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+ relevant_species_trait = HAIR
+
+/datum/preference/color_legacy/gradient_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.gradient_color = value
diff --git a/code/modules/client/preferences/entries/character/species_features/ethereal.dm b/code/modules/client/preferences/entries/character/species_features/ethereal.dm
new file mode 100644
index 0000000000000..9599b3d27a5af
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/ethereal.dm
@@ -0,0 +1,41 @@
+/datum/preference/choiced/ethereal_color
+ db_key = "feature_ethcolor"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Ethereal Color"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/ethereal_color/init_possible_values()
+ var/list/values = list()
+
+ var/icon/ethereal_base = icon('icons/mob/human_parts_greyscale.dmi', "ethereal_head_m")
+ ethereal_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "ethereal_chest_m"), ICON_OVERLAY)
+ ethereal_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "ethereal_l_arm"), ICON_OVERLAY)
+ ethereal_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "ethereal_r_arm"), ICON_OVERLAY)
+
+ var/icon/eyes = icon('icons/mob/human_face.dmi', "eyes")
+ eyes.Blend(COLOR_BLACK, ICON_MULTIPLY)
+ ethereal_base.Blend(eyes, ICON_OVERLAY)
+
+ ethereal_base.Scale(64, 64)
+ ethereal_base.Crop(15, 64, 15 + 31, 64 - 31)
+
+ for (var/name in GLOB.color_list_ethereal)
+ var/color = GLOB.color_list_ethereal[name]
+
+ var/icon/icon = new(ethereal_base)
+ icon.Blend("#[color]", ICON_MULTIPLY)
+ values[name] = icon
+
+ return values
+
+/datum/preference/choiced/ethereal_color/deserialize(input, datum/preferences/preferences)
+ if(findtext(input, GLOB.is_color_nocrunch)) // Migrate old data
+ var/valid = assoc_key_for_value(GLOB.color_list_ethereal, lowertext(input))
+ if(!isnull(valid))
+ return valid
+ return ..()
+
+/datum/preference/choiced/ethereal_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ethcolor"] = GLOB.color_list_ethereal[value]
+
diff --git a/code/modules/client/preferences/entries/character/species_features/felinid.dm b/code/modules/client/preferences/entries/character/species_features/felinid.dm
new file mode 100644
index 0000000000000..bd2e340a48e1f
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/felinid.dm
@@ -0,0 +1,34 @@
+/datum/preference/choiced/tail_human
+ db_key = "feature_human_tail"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ can_randomize = FALSE
+ relevant_mutant_bodypart = "tail_human"
+
+/datum/preference/choiced/tail_human/init_possible_values()
+ return assoc_to_keys(GLOB.tails_roundstart_list_human)
+
+/datum/preference/choiced/tail_human/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["tail_human"] = value
+
+/datum/preference/choiced/tail_human/create_default_value()
+ var/datum/sprite_accessory/tails/human/cat/tail = /datum/sprite_accessory/tails/human/cat
+ return initial(tail.name)
+
+/datum/preference/choiced/ears
+ db_key = "feature_human_ears"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ can_randomize = FALSE
+ relevant_mutant_bodypart = "ears"
+
+/datum/preference/choiced/ears/init_possible_values()
+ return assoc_to_keys(GLOB.ears_list)
+
+/datum/preference/choiced/ears/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ears"] = value
+
+/datum/preference/choiced/ears/create_default_value()
+ var/datum/sprite_accessory/ears/cat/ears = /datum/sprite_accessory/ears/cat
+ return initial(ears.name)
+
diff --git a/code/modules/client/preferences/entries/character/species_features/fly.dm b/code/modules/client/preferences/entries/character/species_features/fly.dm
new file mode 100644
index 0000000000000..a471a6732a617
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/fly.dm
@@ -0,0 +1,11 @@
+/datum/preference/choiced/insect_type
+ db_key = "feature_insect_type"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_mutant_bodypart = "insect_type"
+
+/datum/preference/choiced/insect_type/init_possible_values()
+ return assoc_to_keys(GLOB.insect_type_list)
+
+/datum/preference/choiced/insect_type/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["insect_type"] = value
diff --git a/code/modules/client/preferences/entries/character/species_features/ipc.dm b/code/modules/client/preferences/entries/character/species_features/ipc.dm
new file mode 100644
index 0000000000000..39e7ed201c8e5
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/ipc.dm
@@ -0,0 +1,139 @@
+/datum/preference/choiced/ipc_screen
+ db_key = "feature_ipc_screen"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Screen Style"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "ipc_screen"
+
+/datum/preference/choiced/ipc_screen/init_possible_values()
+ var/list/values = list()
+
+ for (var/screen_name in GLOB.ipc_screens_list)
+ var/datum/sprite_accessory/screen = GLOB.ipc_screens_list[screen_name]
+
+ var/icon/icon_with_screen = icon('icons/mob/species/ipc/bodyparts.dmi', "mcgipc_head", dir = SOUTH)
+ if (screen.icon_state != "none")
+ var/icon/screen_icon = icon(screen.icon, "m_ipc_screen_[screen.icon_state]_ADJ", dir = SOUTH)
+ icon_with_screen.Blend(screen_icon, ICON_OVERLAY)
+ icon_with_screen.Scale(64, 64)
+ icon_with_screen.Crop(15, 64, 15 + 31, 64 - 31)
+
+ values[screen.name] = icon_with_screen
+
+ return values
+
+/datum/preference/choiced/ipc_screen/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ipc_screen"] = value
+
+/datum/preference/choiced/ipc_screen/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "feature_ipc_screen_color"
+
+ return data
+
+/datum/preference/color_legacy/ipc_screen_color
+ db_key = "feature_ipc_screen_color"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+ relevant_mutant_bodypart = "ipc_antenna"
+ priority = PREFERENCE_PRIORITY_EYE_COLOR
+
+/datum/preference/color_legacy/ipc_screen_color/apply_to_human(mob/living/carbon/human/target, value)
+ if(!isipc(target))
+ return
+ target.eye_color = value
+ var/obj/item/organ/eyes/eyes_organ = target.getorgan(/obj/item/organ/eyes)
+ if (istype(eyes_organ))
+ if (!initial(eyes_organ.eye_color))
+ eyes_organ.eye_color = value
+ eyes_organ.old_eye_color = value
+
+/datum/preference/color_legacy/ipc_screen_color/create_default_value()
+ return "fff"
+
+/datum/preference/choiced/ipc_antenna
+ db_key = "feature_ipc_antenna"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Antenna Style"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "ipc_antenna"
+
+/datum/preference/choiced/ipc_antenna/init_possible_values()
+ var/list/values = list()
+
+ for (var/antenna_name in GLOB.ipc_antennas_list)
+ var/datum/sprite_accessory/antenna = GLOB.ipc_antennas_list[antenna_name]
+
+ var/icon/icon_with_antennae = icon('icons/mob/species/ipc/bodyparts.dmi', "mcgipc_head", dir = SOUTH)
+ if (antenna.icon_state != "none")
+ // weird snowflake shit
+ var/side = (antenna_name == "Light" || antenna_name == "Drone Eyes") ? "FRONT" : "ADJ"
+ var/icon/antenna_icon = icon(antenna.icon, "m_ipc_antenna_[antenna.icon_state]_[side]", dir = SOUTH)
+ icon_with_antennae.Blend(antenna_icon, ICON_OVERLAY)
+ icon_with_antennae.Scale(64, 64)
+ icon_with_antennae.Crop(15, 64, 15 + 31, 64 - 31)
+
+ values[antenna.name] = icon_with_antennae
+
+ return values
+
+/datum/preference/choiced/ipc_antenna/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ipc_antenna"] = value
+
+/datum/preference/choiced/ipc_antenna/compile_constant_data()
+ var/list/data = ..()
+
+ data[SUPPLEMENTAL_FEATURE_KEY] = "feature_ipc_antenna_color"
+
+ return data
+
+/datum/preference/color_legacy/ipc_antenna_color
+ db_key = "feature_ipc_antenna_color"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+ relevant_mutant_bodypart = "ipc_antenna"
+
+/datum/preference/color_legacy/ipc_antenna_color/apply_to_human(mob/living/carbon/human/target, value)
+ if(!isipc(target))
+ return
+ target.hair_color = value
+
+/datum/preference/color_legacy/ipc_antenna_color/create_default_value()
+ return "222"
+
+/datum/preference/choiced/ipc_chassis
+ db_key = "feature_ipc_chassis"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Chassis Style"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "ipc_chassis"
+
+/datum/preference/choiced/ipc_chassis/init_possible_values()
+ var/list/values = list()
+ var/list/body_parts = list(
+ BODY_ZONE_HEAD,
+ BODY_ZONE_CHEST,
+ BODY_ZONE_L_ARM,
+ BODY_ZONE_R_ARM,
+ BODY_ZONE_PRECISE_L_HAND,
+ BODY_ZONE_PRECISE_R_HAND,
+ BODY_ZONE_L_LEG,
+ BODY_ZONE_R_LEG,
+ )
+ for (var/chassis_name in GLOB.ipc_chassis_list)
+ var/datum/sprite_accessory/chassis = GLOB.ipc_chassis_list[chassis_name]
+ var/icon/icon_with_chassis = icon('icons/effects/effects.dmi', "nothing")
+
+ for (var/body_part in body_parts)
+ icon_with_chassis.Blend(icon('icons/mob/species/ipc/bodyparts.dmi', "[chassis.limbs_id]_[body_part]", dir = SOUTH), ICON_OVERLAY)
+
+ values[chassis.name] = icon_with_chassis
+
+ return values
+
+/datum/preference/choiced/ipc_chassis/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ipc_chassis"] = value
diff --git a/code/modules/client/preferences/entries/character/species_features/lizard.dm b/code/modules/client/preferences/entries/character/species_features/lizard.dm
new file mode 100644
index 0000000000000..db7ad44659629
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/lizard.dm
@@ -0,0 +1,182 @@
+/proc/generate_lizard_side_shots(list/sprite_accessories, key, include_snout = TRUE)
+ var/list/values = list()
+
+ var/icon/lizard = icon('icons/mob/species/lizard/bodyparts.dmi', "lizard_head_m", dir = EAST)
+
+ var/icon/eyes = icon('icons/mob/human_face.dmi', "eyes", dir = EAST)
+ eyes.Blend(COLOR_GRAY, ICON_MULTIPLY)
+ lizard.Blend(eyes, ICON_OVERLAY)
+
+ if (include_snout)
+ lizard.Blend(icon('icons/mob/mutant_bodyparts.dmi', "m_snout_round_ADJ", dir = EAST), ICON_OVERLAY)
+
+ for (var/name in sprite_accessories)
+ var/datum/sprite_accessory/sprite_accessory = sprite_accessories[name]
+
+ var/icon/final_icon = new(lizard)
+
+ if (sprite_accessory.icon_state != "none")
+ var/icon/accessory_icon = icon(sprite_accessory.icon, "m_[key]_[sprite_accessory.icon_state]_ADJ", dir = EAST)
+ final_icon.Blend(accessory_icon, ICON_OVERLAY)
+
+ final_icon.Crop(11, 20, 23, 32)
+ final_icon.Scale(32, 32)
+ final_icon.Blend(COLOR_LIME, ICON_MULTIPLY)
+
+ values[name] = icon(final_icon, dir = EAST)
+
+ return values
+
+/datum/preference/choiced/lizard_body_markings
+ db_key = "feature_lizard_body_markings"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Body Markings"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "body_markings"
+
+/datum/preference/choiced/lizard_body_markings/init_possible_values()
+ var/list/values = list()
+
+ var/icon/lizard = icon('icons/mob/species/lizard/bodyparts.dmi', "lizard_chest_m", dir = SOUTH)
+
+ for (var/name in GLOB.body_markings_list)
+ var/datum/sprite_accessory/sprite_accessory = GLOB.body_markings_list[name]
+
+ var/icon/final_icon = icon(lizard, dir = SOUTH)
+
+ if (sprite_accessory.icon_state != "none")
+ var/icon/body_markings_icon = icon(
+ 'icons/mob/mutant_bodyparts.dmi',
+ "m_body_markings_[sprite_accessory.icon_state]_ADJ",
+ dir = SOUTH
+ )
+
+ final_icon.Blend(body_markings_icon, ICON_OVERLAY)
+
+ final_icon.Blend(COLOR_LIME, ICON_MULTIPLY)
+ final_icon.Crop(10, 8, 22, 23)
+ final_icon.Scale(26, 32)
+ final_icon.Crop(-2, 1, 29, 32)
+
+ values[name] = icon(final_icon, dir = SOUTH)
+
+ return values
+
+/datum/preference/choiced/lizard_body_markings/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["body_markings"] = value
+
+/datum/preference/choiced/lizard_frills
+ db_key = "feature_lizard_frills"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Frills"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "frills"
+
+/datum/preference/choiced/lizard_frills/init_possible_values()
+ return generate_lizard_side_shots(GLOB.frills_list, "frills")
+
+/datum/preference/choiced/lizard_frills/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["frills"] = value
+
+/datum/preference/choiced/lizard_horns
+ db_key = "feature_lizard_horns"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Horns"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "horns"
+
+/datum/preference/choiced/lizard_horns/init_possible_values()
+ return generate_lizard_side_shots(GLOB.horns_list, "horns")
+
+/datum/preference/choiced/lizard_horns/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["horns"] = value
+
+/datum/preference/choiced/lizard_legs
+ db_key = "feature_lizard_legs"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_mutant_bodypart = "legs"
+
+/datum/preference/choiced/lizard_legs/init_possible_values()
+ return assoc_to_keys(GLOB.legs_list)
+
+/datum/preference/choiced/lizard_legs/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["legs"] = value
+
+/datum/preference/choiced/lizard_snout
+ db_key = "feature_lizard_snout"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Snout"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "snout"
+
+/datum/preference/choiced/lizard_snout/init_possible_values()
+ return generate_lizard_side_shots(GLOB.snouts_list, "snout", include_snout = FALSE)
+
+/datum/preference/choiced/lizard_snout/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["snout"] = value
+
+/datum/preference/choiced/lizard_spines
+ db_key = "feature_lizard_spines"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Spines"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "spines"
+
+/datum/preference/choiced/lizard_spines/init_possible_values()
+ return generate_lizard_body_shots(GLOB.spines_list, "spines", show_tail = TRUE)
+
+/datum/preference/choiced/lizard_spines/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["spines"] = value
+
+/datum/preference/choiced/lizard_tail
+ db_key = "feature_lizard_tail"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Tail"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "tail_lizard"
+
+/datum/preference/choiced/lizard_tail/init_possible_values()
+ return generate_lizard_body_shots(GLOB.tails_list_lizard, "tail")
+
+/datum/preference/choiced/lizard_tail/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["tail_lizard"] = value
+
+/proc/generate_lizard_body_shots(list/sprite_accessories, key, show_tail = FALSE, shift_x = -8)
+ var/list/values = list()
+ var/list/body_parts = list(
+ BODY_ZONE_CHEST,
+ BODY_ZONE_R_ARM,
+ BODY_ZONE_PRECISE_R_HAND,
+ BODY_ZONE_R_LEG,
+ )
+ var/icon/body_icon = icon('icons/effects/effects.dmi', "nothing")
+ for (var/body_part in body_parts)
+ var/gender = body_part == BODY_ZONE_CHEST ? "_m" : ""
+ body_icon.Blend(icon('icons/mob/species/lizard/bodyparts.dmi', "lizard_[body_part][gender]", dir = EAST), ICON_OVERLAY)
+ if(show_tail)
+ body_icon.Blend(icon('icons/mob/mutant_bodyparts.dmi', "m_tail_smooth_BEHIND", dir = EAST), ICON_OVERLAY)
+
+ for (var/sprite_name in sprite_accessories)
+ var/datum/sprite_accessory/sprite = sprite_accessories[sprite_name]
+ var/icon/icon_with_changes = new(body_icon)
+
+ if (sprite_name != "None")
+ var/ex = key == "spines" ? "ADJ" : "BEHIND"
+ var/icon/sprite_icon = icon('icons/mob/mutant_bodyparts.dmi', "m_[key]_[sprite.icon_state]_[ex]", dir = EAST)
+ icon_with_changes.Blend(sprite_icon, ICON_OVERLAY)
+ icon_with_changes.Blend(COLOR_LIME, ICON_MULTIPLY)
+
+ // Zoom in
+ icon_with_changes.Scale(64, 64)
+ icon_with_changes.Crop(15 + shift_x, 0, 15 + 31 + shift_x, 31)
+
+ values[sprite_name] = icon_with_changes
+
+ return values
diff --git a/code/modules/client/preferences/entries/character/species_features/moth.dm b/code/modules/client/preferences/entries/character/species_features/moth.dm
new file mode 100644
index 0000000000000..9013071705cf0
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/moth.dm
@@ -0,0 +1,99 @@
+/datum/preference/choiced/moth_antennae
+ db_key = "feature_moth_antennae"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Antennae"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "moth_antennae"
+
+/datum/preference/choiced/moth_antennae/init_possible_values()
+ var/list/values = list()
+
+ for (var/antennae_name in GLOB.moth_antennae_roundstart_list)
+ var/datum/sprite_accessory/antennae = GLOB.moth_antennae_roundstart_list[antennae_name]
+
+ var/icon/icon_with_antennae = icon('icons/mob/species/moth/bodyparts.dmi', "moth_head_m", dir = SOUTH)
+ icon_with_antennae.Blend(icon(antennae.icon, "m_moth_antennae_[antennae.icon_state]_FRONT", dir = SOUTH), ICON_OVERLAY)
+ icon_with_antennae.Scale(64, 64)
+ icon_with_antennae.Crop(15, 64, 15 + 31, 64 - 31)
+ values[antennae.name] = icon(icon_with_antennae, dir = SOUTH)
+
+ return values
+
+/datum/preference/choiced/moth_antennae/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["moth_antennae"] = value
+
+/datum/preference/choiced/moth_markings
+ db_key = "feature_moth_markings"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Body Markings"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "moth_markings"
+
+/datum/preference/choiced/moth_markings/init_possible_values()
+ var/list/values = list()
+
+ var/icon/moth_body = icon('icons/effects/effects.dmi', "nothing")
+
+ moth_body.Blend(icon('icons/mob/moth_wings.dmi', "m_moth_wings_plain_BEHIND"), ICON_OVERLAY)
+
+ var/list/body_parts = list(
+ BODY_ZONE_HEAD,
+ BODY_ZONE_CHEST,
+ BODY_ZONE_L_ARM,
+ BODY_ZONE_R_ARM,
+ )
+
+ for (var/body_part in body_parts)
+ var/gender = (body_part == "chest" || body_part == "head") ? "_m" : ""
+ moth_body.Blend(icon('icons/mob/species/moth/bodyparts.dmi', "moth_[body_part][gender]", dir = SOUTH), ICON_OVERLAY)
+
+ for (var/markings_name in GLOB.moth_markings_roundstart_list)
+ var/datum/sprite_accessory/markings = GLOB.moth_markings_roundstart_list[markings_name]
+ var/icon/icon_with_markings = new(moth_body)
+
+ if (markings_name != "None")
+ for (var/body_part in body_parts)
+ var/icon/body_part_icon = icon(markings.icon, "[markings.icon_state]_[body_part]", dir = SOUTH)
+ body_part_icon.Crop(1, 1, 32, 32)
+ icon_with_markings.Blend(body_part_icon, ICON_OVERLAY)
+
+ icon_with_markings.Blend(icon('icons/mob/moth_wings.dmi', "m_moth_wings_plain_FRONT"), ICON_OVERLAY)
+ icon_with_markings.Blend(icon('icons/mob/moth_antennae.dmi', "m_moth_antennae_plain_FRONT"), ICON_OVERLAY)
+
+ // Zoom in on the top of the head and the chest
+ icon_with_markings.Scale(64, 64)
+ icon_with_markings.Crop(15, 64, 15 + 31, 64 - 31)
+
+ values[markings.name] = icon_with_markings
+
+ return values
+
+/datum/preference/choiced/moth_markings/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["moth_markings"] = value
+
+/datum/preference/choiced/moth_wings
+ db_key = "feature_moth_wings"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "Moth Wings"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "moth_wings"
+
+/datum/preference/choiced/moth_wings/init_possible_values()
+ var/list/icon/values = possible_values_for_sprite_accessory_list_for_body_part(
+ GLOB.moth_wings_roundstart_list,
+ "moth_wings",
+ list("BEHIND", "FRONT"),
+ )
+
+ // Moth wings are in a stupid dimension
+ for (var/name in values)
+ values[name].Crop(1, 1, 32, 32)
+
+ return values
+
+/datum/preference/choiced/moth_wings/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["moth_wings"] = value
+
diff --git a/code/modules/client/preferences/entries/character/species_features/mutants.dm b/code/modules/client/preferences/entries/character/species_features/mutants.dm
new file mode 100644
index 0000000000000..0a1897793aca9
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/mutants.dm
@@ -0,0 +1,20 @@
+/datum/preference/color_legacy/mutant_color
+ db_key = "feature_mcolor"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_species_trait = MUTCOLORS
+
+/datum/preference/color_legacy/mutant_color/create_default_value()
+ return sanitize_hexcolor("[pick("7F", "FF")][pick("7F", "FF")][pick("7F", "FF")]")
+
+/datum/preference/color_legacy/mutant_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["mcolor"] = value
+
+/datum/preference/color_legacy/mutant_color/is_valid(value)
+ if (!..(value))
+ return FALSE
+
+ if (is_color_dark(expand_three_digit_color(value)))
+ return FALSE
+
+ return TRUE
diff --git a/code/modules/client/preferences/entries/character/species_features/plasmaman.dm b/code/modules/client/preferences/entries/character/species_features/plasmaman.dm
new file mode 100644
index 0000000000000..bd76cc178809a
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/plasmaman.dm
@@ -0,0 +1,14 @@
+/datum/preference/choiced/helmet_style
+ db_key = "helmet_style"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ relevant_species_trait = ENVIROSUIT
+
+/datum/preference/choiced/helmet_style/init_possible_values()
+ return assoc_to_keys(GLOB.helmet_styles)
+
+/datum/preference/choiced/helmet_style/create_default_value()
+ return HELMET_DEFAULT
+
+/datum/preference/choiced/helmet_style/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/entries/character/species_features/psyphoza.dm b/code/modules/client/preferences/entries/character/species_features/psyphoza.dm
new file mode 100644
index 0000000000000..ab2322dd9fee1
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/species_features/psyphoza.dm
@@ -0,0 +1,27 @@
+/datum/preference/choiced/psyphoza_cap
+ db_key = "feature_psyphoza_cap"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_FEATURES
+ main_feature_name = "cap"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "psyphoza_cap"
+
+/datum/preference/choiced/psyphoza_cap/init_possible_values()
+ var/list/values = list()
+
+ for (var/cap_name in GLOB.psyphoza_cap_list)
+ var/datum/sprite_accessory/cap = GLOB.psyphoza_cap_list[cap_name]
+
+ var/icon/icon_with_cap = icon('icons/mob/species/psyphoza/bodyparts.dmi', "psyphoza_head", dir = SOUTH)
+ if (cap.icon_state != "none")
+ var/icon/screen_icon = icon(cap.icon, "m_psyphoza_cap_[cap.icon_state]_ADJ", dir = SOUTH)
+ icon_with_cap.Blend(screen_icon, ICON_OVERLAY)
+ icon_with_cap.Scale(64, 64)
+ icon_with_cap.Crop(15, 64, 15 + 31, 64 - 31)
+
+ values[cap.name] = icon_with_cap
+
+ return values
+
+/datum/preference/choiced/psyphoza_cap/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["psyphoza_cap"] = value
diff --git a/code/modules/client/preferences/entries/character/underwear_color.dm b/code/modules/client/preferences/entries/character/underwear_color.dm
new file mode 100644
index 0000000000000..78b1eeea58017
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/underwear_color.dm
@@ -0,0 +1,15 @@
+/datum/preference/color_legacy/underwear_color
+ db_key = "underwear_color"
+ preference_type = PREFERENCE_CHARACTER
+ category = PREFERENCE_CATEGORY_SUPPLEMENTAL_FEATURES
+
+/datum/preference/color_legacy/underwear_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.underwear_color = value
+
+/datum/preference/color_legacy/underwear_color/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+
+ var/species_type = preferences.read_character_preference(/datum/preference/choiced/species)
+ var/datum/species/species = new species_type
+ return !(NO_UNDERWEAR in species.species_traits)
diff --git a/code/modules/client/preferences/entries/character/uplink_location.dm b/code/modules/client/preferences/entries/character/uplink_location.dm
new file mode 100644
index 0000000000000..6154c33889564
--- /dev/null
+++ b/code/modules/client/preferences/entries/character/uplink_location.dm
@@ -0,0 +1,26 @@
+/datum/preference/choiced/uplink_location
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ preference_type = PREFERENCE_CHARACTER
+ db_key = "uplink_loc"
+ can_randomize = FALSE
+
+/datum/preference/choiced/uplink_location/init_possible_values()
+ return list(UPLINK_PDA, UPLINK_RADIO, UPLINK_PEN, UPLINK_IMPLANT)
+
+/datum/preference/choiced/uplink_location/compile_constant_data()
+ var/list/data = ..()
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = list(
+ UPLINK_PDA = "PDA",
+ UPLINK_RADIO = "Radio",
+ UPLINK_PEN = "Pen",
+ UPLINK_IMPLANT = "Implant ([UPLINK_IMPLANT_TELECRYSTAL_COST]TC)",
+ )
+
+ return data
+
+/datum/preference/choiced/uplink_location/create_default_value()
+ return UPLINK_PDA
+
+/datum/preference/choiced/uplink_location/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/entries/player/admin.dm b/code/modules/client/preferences/entries/player/admin.dm
new file mode 100644
index 0000000000000..9037723a2282e
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/admin.dm
@@ -0,0 +1,31 @@
+/datum/preference/color/asay_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "asaycolor"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/color/asay_color/create_default_value()
+ return DEFAULT_ASAY_COLOR
+
+/datum/preference/color/asay_color/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+
+ return is_admin(preferences.parent) && CONFIG_GET(flag/allow_admin_asaycolor)
+
+/datum/preference/toggle/announce_login
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "announce_login"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/announce_login/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && is_admin(preferences.parent)
+
+/datum/preference/toggle/combohud_lighting
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "combohud_lighting"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/combohud_lighting/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && is_admin(preferences.parent)
diff --git a/code/modules/client/preferences/entries/player/ambient_occlusion.dm b/code/modules/client/preferences/entries/player/ambient_occlusion.dm
new file mode 100644
index 0000000000000..5e94743ae3834
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/ambient_occlusion.dm
@@ -0,0 +1,12 @@
+/// Whether or not to toggle ambient occlusion, the shadows around people
+/datum/preference/toggle/ambient_occlusion
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "ambientocclusion"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/ambient_occlusion/apply_to_client(client/client, value)
+ var/atom/movable/screen/plane_master/game_world/plane_master = locate() in client?.screen
+ if (!plane_master)
+ return
+
+ plane_master.backdrop(client.mob)
diff --git a/code/modules/client/preferences/entries/player/auto_fit_viewport.dm b/code/modules/client/preferences/entries/player/auto_fit_viewport.dm
new file mode 100644
index 0000000000000..462b0601e7a3d
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/auto_fit_viewport.dm
@@ -0,0 +1,7 @@
+/datum/preference/toggle/auto_fit_viewport
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "auto_fit_viewport"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/auto_fit_viewport/apply_to_client_updated(client/client, value)
+ INVOKE_ASYNC(client, /client/verb/fit_viewport)
diff --git a/code/modules/client/preferences/entries/player/buttons_locked.dm b/code/modules/client/preferences/entries/player/buttons_locked.dm
new file mode 100644
index 0000000000000..faad04e9e7888
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/buttons_locked.dm
@@ -0,0 +1,5 @@
+/datum/preference/toggle/buttons_locked
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "buttons_locked"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
diff --git a/code/modules/client/preferences/entries/player/chat.dm b/code/modules/client/preferences/entries/player/chat.dm
new file mode 100644
index 0000000000000..066c5175a0281
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/chat.dm
@@ -0,0 +1,79 @@
+/datum/preference/toggle/chat_bankcard
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_bankcard"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_dead
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_dead"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_dead/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+ return is_admin(preferences.parent)
+
+/datum/preference/toggle/chat_followghostmindless
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_followghostmindless"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_ghostears
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_ghostears"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_ghostlaws
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_ghostlaws"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_ghostpda
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_ghostpda"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_ghostradio
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_ghostradio"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_ghostsight
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_ghostsight"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_ghostwhisper
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_ghostwhisper"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_ooc
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_ooc"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_prayer
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_prayer"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_prayer/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+ return is_admin(preferences.parent)
+
+/datum/preference/toggle/chat_pullr
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_pullr"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_radio
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_radio"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/chat_radio/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+ return is_admin(preferences.parent)
diff --git a/code/modules/client/preferences/entries/player/crew_objectives.dm b/code/modules/client/preferences/entries/player/crew_objectives.dm
new file mode 100644
index 0000000000000..4e6250fd4205c
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/crew_objectives.dm
@@ -0,0 +1,4 @@
+/datum/preference/toggle/crew_objectives
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "crew_objectives"
+ preference_type = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/entries/player/deadmin.dm b/code/modules/client/preferences/entries/player/deadmin.dm
new file mode 100644
index 0000000000000..dd1b985529f98
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/deadmin.dm
@@ -0,0 +1,69 @@
+/datum/preference/toggle/deadmin_always
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "deadmin_always"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/deadmin_always/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && is_admin(preferences.parent)
+
+/datum/preference/toggle/deadmin_always/compile_constant_data()
+ return list(
+ "forced" = CONFIG_GET(flag/auto_deadmin_players),
+ )
+
+/datum/preference/toggle/deadmin_antagonist
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "deadmin_antagonist"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/deadmin_antagonist/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && is_admin(preferences.parent) && !preferences.read_player_preference(/datum/preference/toggle/deadmin_always)
+
+/datum/preference/toggle/deadmin_antagonist/compile_constant_data()
+ return list(
+ "forced" = CONFIG_GET(flag/auto_deadmin_antagonists),
+ )
+
+/datum/preference/toggle/deadmin_position_head
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "deadmin_position_head"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/deadmin_position_head/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && is_admin(preferences.parent) && !preferences.read_player_preference(/datum/preference/toggle/deadmin_always)
+
+/datum/preference/toggle/deadmin_position_head/compile_constant_data()
+ return list(
+ "forced" = CONFIG_GET(flag/auto_deadmin_heads),
+ )
+
+/datum/preference/toggle/deadmin_position_security
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "deadmin_position_security"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/deadmin_position_security/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && is_admin(preferences.parent) && !preferences.read_player_preference(/datum/preference/toggle/deadmin_always)
+
+/datum/preference/toggle/deadmin_position_security/compile_constant_data()
+ return list(
+ "forced" = CONFIG_GET(flag/auto_deadmin_security),
+ )
+
+/datum/preference/toggle/deadmin_position_silicon
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "deadmin_position_silicon"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/deadmin_position_silicon/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && is_admin(preferences.parent) && !preferences.read_player_preference(/datum/preference/toggle/deadmin_always)
+
+/datum/preference/toggle/deadmin_position_silicon/compile_constant_data()
+ return list(
+ "forced" = CONFIG_GET(flag/auto_deadmin_silicons),
+ )
diff --git a/code/modules/client/preferences/entries/player/fps.dm b/code/modules/client/preferences/entries/player/fps.dm
new file mode 100644
index 0000000000000..dd5a2a28ee57b
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/fps.dm
@@ -0,0 +1,20 @@
+/datum/preference/numeric/fps
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "clientfps"
+ preference_type = PREFERENCE_PLAYER
+
+ minimum = -1
+ maximum = 240
+
+/datum/preference/numeric/fps/create_default_value()
+ return -1 // use the default
+
+/datum/preference/numeric/fps/apply_to_client(client/client, value)
+ client.fps = (value < 0) ? 40 : value
+
+/datum/preference/numeric/fps/compile_constant_data()
+ var/list/data = ..()
+
+ data["recommended_fps"] = 40
+
+ return data
diff --git a/code/modules/client/preferences/entries/player/ghost.dm b/code/modules/client/preferences/entries/player/ghost.dm
new file mode 100644
index 0000000000000..36e7aee4cf9e9
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/ghost.dm
@@ -0,0 +1,151 @@
+/// Determines what accessories your ghost will look like they have.
+/datum/preference/choiced/ghost_accessories
+ db_key = "ghost_accs"
+ preference_type = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/choiced/ghost_accessories/init_possible_values()
+ return list(GHOST_ACCS_NONE, GHOST_ACCS_DIR, GHOST_ACCS_FULL)
+
+/datum/preference/choiced/ghost_accessories/create_default_value()
+ return GHOST_ACCS_DEFAULT_OPTION
+
+/datum/preference/choiced/ghost_accessories/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ ghost.ghost_accs = value
+ ghost.update_appearance()
+
+/datum/preference/choiced/ghost_accessories/deserialize(input, datum/preferences/preferences)
+ // Old ghost preferences used to be 1/50/100.
+ // Whoever did that wasted an entire day of my time trying to get those sent
+ // properly, so I'm going to buck them.
+ if (isnum(input))
+ switch (input)
+ if (1)
+ input = GHOST_ACCS_NONE
+ if (50)
+ input = GHOST_ACCS_DIR
+ if (100)
+ input = GHOST_ACCS_FULL
+
+ return ..(input)
+
+/// Determines the appearance of your ghost to others, when you are a BYOND member
+/datum/preference/choiced/ghost_form
+ db_key = "ghost_form"
+ preference_type = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/ghost_form/init_possible_values()
+ var/list/values = list()
+
+ for (var/ghost_form in GLOB.ghost_forms)
+ values[ghost_form] = icon('icons/mob/mob.dmi', ghost_form)
+
+ return values
+
+/datum/preference/choiced/ghost_form/create_default_value()
+ return "ghost"
+
+/datum/preference/choiced/ghost_form/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ if (!client.is_content_unlocked())
+ return
+
+ ghost.update_icon(ALL, value)
+
+/datum/preference/choiced/ghost_form/compile_constant_data()
+ var/list/data = ..()
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = GLOB.ghost_forms
+
+ return data
+
+/// Toggles the HUD for ghosts
+/datum/preference/toggle/ghost_hud
+ db_key = "ghost_hud"
+ preference_type = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/toggle/ghost_hud/apply_to_client(client/client, value)
+ if (isobserver(client?.mob))
+ client?.mob.hud_used?.show_hud()
+
+/// Determines what ghosts orbiting look like to you.
+/datum/preference/choiced/ghost_orbit
+ db_key = "ghost_orbit"
+ preference_type = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/choiced/ghost_orbit/init_possible_values()
+ return list(
+ GHOST_ORBIT_CIRCLE,
+ GHOST_ORBIT_TRIANGLE,
+ GHOST_ORBIT_SQUARE,
+ GHOST_ORBIT_HEXAGON,
+ GHOST_ORBIT_PENTAGON,
+ )
+
+/datum/preference/choiced/ghost_orbit/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ if (!client.is_content_unlocked())
+ return
+
+ ghost.ghost_orbit = value
+
+/datum/preference/choiced/ghost_orbit/create_default_value()
+ return GHOST_ORBIT_CIRCLE
+
+/// Determines how to show other ghosts
+/datum/preference/choiced/ghost_others
+ db_key = "ghost_others"
+ preference_type = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/choiced/ghost_others/init_possible_values()
+ return list(
+ GHOST_OTHERS_SIMPLE,
+ GHOST_OTHERS_DEFAULT_SPRITE,
+ GHOST_OTHERS_THEIR_SETTING,
+ )
+
+/datum/preference/choiced/ghost_others/create_default_value()
+ return GHOST_OTHERS_DEFAULT_OPTION
+
+/datum/preference/choiced/ghost_others/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ ghost.update_sight()
+
+/datum/preference/choiced/ghost_others/deserialize(input, datum/preferences/preferences)
+ // Old ghost preferences used to be 1/50/100.
+ // Whoever did that wasted an entire day of my time trying to get those sent
+ // properly, so I'm going to buck them.
+ if (isnum(input))
+ switch (input)
+ if (1)
+ input = GHOST_OTHERS_SIMPLE
+ if (50)
+ input = GHOST_OTHERS_DEFAULT_SPRITE
+ if (100)
+ input = GHOST_OTHERS_THEIR_SETTING
+
+ return ..(input, preferences)
+
+/// Whether or not ghosts can examine things by clicking on them.
+/datum/preference/toggle/inquisitive_ghost
+ db_key = "inquisitive_ghost"
+ preference_type = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
diff --git a/code/modules/client/preferences/entries/player/glasses.dm b/code/modules/client/preferences/entries/player/glasses.dm
new file mode 100644
index 0000000000000..50fc409553dd5
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/glasses.dm
@@ -0,0 +1,14 @@
+/datum/preference/toggle/glasses_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ default_value = FALSE
+ db_key = "glasses_color"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/glasses_color/apply_to_client(client/client, value)
+ if(!ishuman(client.mob))
+ return
+ var/mob/living/carbon/human/H = client.mob
+ var/obj/item/clothing/glasses/G = H.glasses
+ if(!istype(G) || !G.glass_colour_type)
+ return
+ H.update_glasses_color(G, TRUE)
diff --git a/code/modules/client/preferences/entries/player/hotkeys.dm b/code/modules/client/preferences/entries/player/hotkeys.dm
new file mode 100644
index 0000000000000..f4c9a1b19f655
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/hotkeys.dm
@@ -0,0 +1,7 @@
+/datum/preference/toggle/hotkeys
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "hotkeys"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/hotkeys/apply_to_client(client/client, value)
+ client.hotkeys = value
diff --git a/code/modules/client/preferences/entries/player/item_outlines.dm b/code/modules/client/preferences/entries/player/item_outlines.dm
new file mode 100644
index 0000000000000..a35900fbc2ace
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/item_outlines.dm
@@ -0,0 +1,12 @@
+/datum/preference/toggle/item_outlines
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "itemoutline_pref"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/color/outline_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "outline_color"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/color/outline_color/create_default_value()
+ return COLOR_BLUE_GRAY
diff --git a/code/modules/client/preferences/entries/player/jobless_role.dm b/code/modules/client/preferences/entries/player/jobless_role.dm
new file mode 100644
index 0000000000000..81ee431dd1935
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/jobless_role.dm
@@ -0,0 +1,12 @@
+/datum/preference/choiced/jobless_role
+ db_key = "joblessrole"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/choiced/jobless_role/create_default_value()
+ return BEOVERFLOW
+
+/datum/preference/choiced/jobless_role/init_possible_values()
+ return list(BEOVERFLOW, BERANDOMJOB, RETURNTOLOBBY)
+
+/datum/preference/choiced/jobless_role/should_show_on_page(preference_tab)
+ return preference_tab == PREFERENCE_TAB_CHARACTER_PREFERENCES
diff --git a/code/modules/client/preferences/entries/player/ooc.dm b/code/modules/client/preferences/entries/player/ooc.dm
new file mode 100644
index 0000000000000..c0b044b83d973
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/ooc.dm
@@ -0,0 +1,23 @@
+/// The color admins will speak in for OOC.
+/datum/preference/color/ooc_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "ooccolor"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/color/ooc_color/create_default_value()
+ return DEFAULT_BONUS_OOC_COLOR
+
+/datum/preference/color/ooc_color/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+
+ return is_admin(preferences.parent) || preferences.unlock_content
+
+/datum/preference/toggle/member_public
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "member_public"
+ preference_type = PREFERENCE_PLAYER
+ default_value = TRUE
+
+/datum/preference/toggle/member_public/is_accessible(datum/preferences/preferences, ignore_page)
+ return ..() && preferences.unlock_content
diff --git a/code/modules/client/preferences/entries/player/parallax.dm b/code/modules/client/preferences/entries/player/parallax.dm
new file mode 100644
index 0000000000000..f2e4577c91e0d
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/parallax.dm
@@ -0,0 +1,38 @@
+/// Determines parallax, "fancy space"
+/datum/preference/choiced/parallax
+ db_key = "parallax"
+ preference_type = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+
+/datum/preference/choiced/parallax/init_possible_values()
+ return list(
+ PARALLAX_INSANE,
+ PARALLAX_HIGH,
+ PARALLAX_MED,
+ PARALLAX_LOW,
+ PARALLAX_DISABLE,
+ )
+
+/datum/preference/choiced/parallax/create_default_value()
+ return PARALLAX_HIGH
+
+/datum/preference/choiced/parallax/apply_to_client(client/client, value)
+ client.mob?.hud_used?.update_parallax_pref(client?.mob)
+
+/datum/preference/choiced/parallax/deserialize(input, datum/preferences/preferences)
+ // Old preferences were numbers, which causes annoyances when
+ // sending over as lists that isn't worth dealing with.
+ if (isnum(input))
+ switch (input)
+ if (-1)
+ input = PARALLAX_INSANE
+ if (0)
+ input = PARALLAX_HIGH
+ if (1)
+ input = PARALLAX_MED
+ if (2)
+ input = PARALLAX_LOW
+ if (3)
+ input = PARALLAX_DISABLE
+
+ return ..(input)
diff --git a/code/modules/client/preferences/entries/player/pixel_size.dm b/code/modules/client/preferences/entries/player/pixel_size.dm
new file mode 100644
index 0000000000000..4db8ded6fcec0
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/pixel_size.dm
@@ -0,0 +1,15 @@
+/datum/preference/numeric/pixel_size
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "pixel_size"
+ preference_type = PREFERENCE_PLAYER
+
+ minimum = 0
+ maximum = 3
+
+ step = 0.5
+
+/datum/preference/numeric/pixel_size/create_default_value()
+ return 0
+
+/datum/preference/numeric/pixel_size/apply_to_client(client/client, value)
+ client?.view_size?.resetFormat()
diff --git a/code/modules/client/preferences/entries/player/preferred_map.dm b/code/modules/client/preferences/entries/player/preferred_map.dm
new file mode 100644
index 0000000000000..a6fa091a02223
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/preferred_map.dm
@@ -0,0 +1,52 @@
+/// During map rotation, this will help determine the chosen map.
+/datum/preference/choiced/preferred_map
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "preferred_map"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/choiced/preferred_map/init_possible_values()
+ var/list/maps = list()
+ maps += "Default"
+
+ for (var/map in config.maplist)
+ var/datum/map_config/map_config = config.maplist[map]
+ if (!map_config.votable)
+ continue
+
+ maps += map
+
+ return maps
+
+/datum/preference/choiced/preferred_map/create_default_value()
+ return "Default"
+
+/datum/preference/choiced/preferred_map/compile_constant_data()
+ var/list/data = ..()
+
+ var/display_names = list()
+
+ if (config.defaultmap)
+ display_names["Default"] = "Default ([config.defaultmap.map_name])"
+ else
+ display_names["Default"] = "Default"
+
+ for (var/choice in get_choices())
+ if (choice == "Default")
+ continue
+
+ var/datum/map_config/map_config = config.maplist[choice]
+
+ var/map_name = map_config.map_name
+ if (map_config.voteweight <= 0)
+ map_name += " (disabled)"
+ display_names[choice] = map_name
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = display_names
+
+ return data
+
+/datum/preference/choiced/preferred_map/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+
+ return CONFIG_GET(flag/preference_map_voting)
diff --git a/code/modules/client/preferences/entries/player/rattle.dm b/code/modules/client/preferences/entries/player/rattle.dm
new file mode 100644
index 0000000000000..5fb0382cc47ae
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/rattle.dm
@@ -0,0 +1,9 @@
+/datum/preference/toggle/death_rattle
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "death_rattle"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/arrivals_rattle
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "arrivals_rattle"
+ preference_type = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/entries/player/roundend.dm b/code/modules/client/preferences/entries/player/roundend.dm
new file mode 100644
index 0000000000000..968df7661c4e8
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/roundend.dm
@@ -0,0 +1,4 @@
+/datum/preference/toggle/show_credits
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "show_credits"
+ preference_type = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/entries/player/runechat.dm b/code/modules/client/preferences/entries/player/runechat.dm
new file mode 100644
index 0000000000000..af6e186397192
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/runechat.dm
@@ -0,0 +1,25 @@
+/datum/preference/toggle/enable_runechat
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "chat_on_map"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/enable_runechat_non_mobs
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "see_chat_non_mob"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/see_rc_emotes
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "see_rc_emotes"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/choiced/show_balloon_alerts
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "show_balloon_alerts"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/choiced/show_balloon_alerts/create_default_value()
+ return BALLOON_ALERT_ALWAYS
+
+/datum/preference/choiced/show_balloon_alerts/init_possible_values()
+ return list(BALLOON_ALERT_ALWAYS, BALLOON_ALERT_WITH_CHAT, BALLOON_ALERT_NEVER)
diff --git a/code/modules/client/preferences/entries/player/scaling_method.dm b/code/modules/client/preferences/entries/player/scaling_method.dm
new file mode 100644
index 0000000000000..920400d64c6df
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/scaling_method.dm
@@ -0,0 +1,14 @@
+/// The scaling method to show the world in, e.g. nearest neighbor
+/datum/preference/choiced/scaling_method
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "scaling_method"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/choiced/scaling_method/create_default_value()
+ return SCALING_METHOD_DISTORT
+
+/datum/preference/choiced/scaling_method/init_possible_values()
+ return list(SCALING_METHOD_DISTORT, SCALING_METHOD_BLUR, SCALING_METHOD_NORMAL)
+
+/datum/preference/choiced/scaling_method/apply_to_client(client/client, value)
+ client?.view_size?.setZoomMode()
diff --git a/code/modules/client/preferences/entries/player/sound.dm b/code/modules/client/preferences/entries/player/sound.dm
new file mode 100644
index 0000000000000..909f7c3606368
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/sound.dm
@@ -0,0 +1,88 @@
+/datum/preference/toggle/sound_adminhelp
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_adminhelp"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_adminhelp/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ if (!..())
+ return FALSE
+
+ return is_admin(preferences.parent)
+
+/datum/preference/toggle/sound_midi
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_midi"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_midi/apply_to_client(client/client, value)
+ if(!value)
+ client.mob?.stop_sound_channel(CHANNEL_ADMIN)
+ client.tgui_panel?.stop_music()
+
+/datum/preference/toggle/sound_ambience
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_ambience"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_ambience/apply_to_client(client/client, value)
+ if(value)
+ SSambience.add_ambience_client(client)
+ else
+ client.mob?.stop_sound_channel(CHANNEL_AMBIENT_EFFECTS)
+ client.mob?.stop_sound_channel(CHANNEL_AMBIENT_MUSIC)
+ client.mob?.stop_sound_channel(CHANNEL_BUZZ)
+ client.buzz_playing = FALSE
+ SSambience.remove_ambience_client(client)
+
+/datum/preference/toggle/sound_lobby
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_lobby"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_lobby/apply_to_client(client/client, value)
+ if (value && isnewplayer(client.mob))
+ if(SSticker.login_music)
+ client.playtitlemusic()
+ else
+ client.mob?.stop_sound_channel(CHANNEL_LOBBYMUSIC)
+
+/datum/preference/toggle/sound_instruments
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_instruments"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_ship_ambience
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_ship_ambience"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_ship_ambience/apply_to_client(client/client, value)
+ if(!value)
+ client.mob?.stop_sound_channel(CHANNEL_BUZZ)
+ client.buzz_playing = FALSE
+
+/datum/preference/toggle/sound_prayers
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_prayers"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_adminalert
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_adminalert"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_announcements
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_announcements"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_soundtrack
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "sound_soundtrack"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/sound_soundtrack/apply_to_client(client/client, value)
+ if (value)
+ client.mob?.play_current_soundtrack()
+ else
+ client.mob?.stop_sound_channel(CHANNEL_SOUNDTRACK)
diff --git a/code/modules/client/preferences/entries/player/tgui.dm b/code/modules/client/preferences/entries/player/tgui.dm
new file mode 100644
index 0000000000000..a6c1af54a4e12
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/tgui.dm
@@ -0,0 +1,77 @@
+/datum/preference/toggle/tgui_fancy
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tgui_fancy"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/tgui_fancy/apply_to_client(client/client, value)
+ for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis)
+ // Force it to reload either way
+ tgui.update_static_data(client.mob)
+
+/datum/preference/toggle/tgui_lock
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tgui_lock"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/tgui_lock/apply_to_client(client/client, value)
+ for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis)
+ // Force it to reload either way
+ tgui.update_static_data(client.mob)
+
+// Determines if input boxes are in tgui or old fashioned
+/datum/preference/toggle/tgui_input
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tgui_input"
+ preference_type = PREFERENCE_PLAYER
+
+/// Large button preference. Error text is in tooltip.
+/datum/preference/toggle/tgui_input_large
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tgui_input_large"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/tgui_input_large/apply_to_client(client/client, value)
+ for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis)
+ // Force it to reload either way
+ tgui.send_full_update(client.mob)
+
+/// Swapped button state - sets buttons to SS13 traditional SUBMIT/CANCEL
+/datum/preference/toggle/tgui_input_swapped
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tgui_input_swapped"
+ preference_type = PREFERENCE_PLAYER
+
+/datum/preference/toggle/tgui_input_swapped/apply_to_client(client/client, value)
+ for (var/datum/tgui/tgui as anything in client.mob?.tgui_open_uis)
+ // Force it to reload either way
+ tgui.send_full_update(client.mob)
+
+/// TGUI Say vs Classic Say
+/datum/preference/toggle/tgui_say
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tgui_say"
+ preference_type = PREFERENCE_PLAYER
+ default_value = TRUE
+
+/datum/preference/toggle/tgui_say/apply_to_client(client/client)
+ client.tgui_say?.load()
+
+/// Light mode for tgui say
+/datum/preference/toggle/tgui_say_light_mode
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tgui_say_light_mode"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/tgui_say_light_mode/apply_to_client(client/client)
+ client.tgui_say?.load()
+
+/datum/preference/toggle/tgui_say_show_prefix
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tgui_say_show_prefix"
+ preference_type = PREFERENCE_PLAYER
+ default_value = FALSE
+
+/datum/preference/toggle/tgui_say_show_prefix/apply_to_client(client/client)
+ client.tgui_say?.load()
diff --git a/code/modules/client/preferences/entries/player/tooltips.dm b/code/modules/client/preferences/entries/player/tooltips.dm
new file mode 100644
index 0000000000000..c5ca3b632f5db
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/tooltips.dm
@@ -0,0 +1,15 @@
+/datum/preference/numeric/tooltip_delay
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "tip_delay"
+ preference_type = PREFERENCE_PLAYER
+
+ minimum = 0
+ maximum = 5000
+
+/datum/preference/numeric/tooltip_delay/create_default_value()
+ return 500
+
+/datum/preference/toggle/enable_tooltips
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "enable_tips"
+ preference_type = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/entries/player/ui_style.dm b/code/modules/client/preferences/entries/player/ui_style.dm
new file mode 100644
index 0000000000000..ba987e5a336e2
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/ui_style.dm
@@ -0,0 +1,31 @@
+/// UI style preference
+/datum/preference/choiced/ui_style
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ preference_type = PREFERENCE_PLAYER
+ db_key = "ui_style"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/ui_style/init_possible_values()
+ var/list/values = list()
+
+ for (var/style in GLOB.available_ui_styles)
+ var/icon/icons = GLOB.available_ui_styles[style]
+
+ var/icon/icon = icon(icons, "hand_r")
+ icon.Crop(1, 1, world.icon_size * 2, world.icon_size)
+ icon.Blend(icon(icons, "hand_l"), ICON_OVERLAY, world.icon_size)
+
+ values[style] = icon
+
+ return values
+
+/datum/preference/choiced/ui_style/create_default_value()
+ return GLOB.available_ui_styles[1]
+
+/datum/preference/choiced/ui_style/apply_to_client(client/client, value)
+ client.mob?.hud_used?.update_ui_style(ui_style2icon(value))
+
+/datum/preference/toggle/intent_style
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "intent_style"
+ preference_type = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/entries/player/window_flashing.dm b/code/modules/client/preferences/entries/player/window_flashing.dm
new file mode 100644
index 0000000000000..4cc7d370109ed
--- /dev/null
+++ b/code/modules/client/preferences/entries/player/window_flashing.dm
@@ -0,0 +1,5 @@
+/// Enables flashing the window in your task tray for important events
+/datum/preference/toggle/window_flashing
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ db_key = "windowflashing"
+ preference_type = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/middleware/_middleware.dm b/code/modules/client/preferences/middleware/_middleware.dm
new file mode 100644
index 0000000000000..8f47f73642c80
--- /dev/null
+++ b/code/modules/client/preferences/middleware/_middleware.dm
@@ -0,0 +1,52 @@
+/// Preference middleware is code that helps to decentralize complicated preference features.
+/datum/preference_middleware
+ /// The preferences datum
+ var/datum/preferences/preferences
+
+ /// The key that will be used for get_constant_data().
+ /// If null, will use the typepath minus /datum/preference_middleware.
+ var/key = null
+
+ /// Map of ui_act actions -> proc paths to call.
+ /// Signature is `(list/params, mob/user) -> TRUE/FALSE.
+ /// Return output is the same as ui_act--TRUE if it should update, FALSE if it should not
+ var/list/action_delegations = list()
+
+/datum/preference_middleware/New(datum/preferences)
+ src.preferences = preferences
+
+ if (isnull(key))
+ // + 2 coming from the off-by-one of copytext, and then another from the slash
+ key = copytext("[type]", length("[parent_type]") + 2)
+
+/datum/preference_middleware/Destroy()
+ preferences = null
+ return ..()
+
+/// Append all of these into ui_data
+/datum/preference_middleware/proc/get_ui_data(mob/user)
+ return list()
+
+/// Append all of these into ui_static_data
+/datum/preference_middleware/proc/get_ui_static_data(mob/user)
+ return list()
+
+/// Append all of these into ui_assets
+/datum/preference_middleware/proc/get_ui_assets()
+ return list()
+
+/// Append all of these into /datum/asset/json/preferences.
+/datum/preference_middleware/proc/get_constant_data()
+ return null
+
+/// Merge this into the result of compile_character_preferences.
+/datum/preference_middleware/proc/get_character_preferences(mob/user)
+ return null
+
+/// Called every set_preference, returns TRUE if this handled it.
+/datum/preference_middleware/proc/pre_set_preference(mob/user, preference, value)
+ return FALSE
+
+/// Called when a character is changed.
+/datum/preference_middleware/proc/on_new_character(mob/user)
+ return
diff --git a/code/modules/client/preferences/middleware/antags.dm b/code/modules/client/preferences/middleware/antags.dm
new file mode 100644
index 0000000000000..c49693f3783ac
--- /dev/null
+++ b/code/modules/client/preferences/middleware/antags.dm
@@ -0,0 +1,167 @@
+/datum/preference_middleware/antags
+ action_delegations = list(
+ "set_antags" = PROC_REF(set_antags),
+ )
+
+/datum/preference_middleware/antags/get_ui_data(mob/user)
+ if (preferences.current_window != PREFERENCE_TAB_CHARACTER_PREFERENCES)
+ return list()
+ var/list/data = list()
+ var/list/enabled_global = list()
+ var/list/enabled_character = list()
+ for(var/datum/role_preference/pref_type as anything in GLOB.role_preference_entries)
+ var/role_preference_value = preferences.role_preferences_global["[pref_type]"]
+ if(isnum(role_preference_value) && !role_preference_value) // explicitly disabled
+ continue
+ enabled_global += "[pref_type]"
+
+ for(var/datum/role_preference/pref_type as anything in GLOB.role_preference_entries)
+ if(!initial(pref_type.per_character))
+ continue
+ var/role_preference_value = preferences.role_preferences["[pref_type]"]
+ if(isnum(role_preference_value) && !role_preference_value) // explicitly disabled
+ continue
+ enabled_character += "[pref_type]"
+ data["enabled_global"] = enabled_global
+ data["enabled_character"] = enabled_character
+ return data
+
+/datum/preference_middleware/antags/get_ui_static_data(mob/user)
+ if (preferences.current_window != PREFERENCE_TAB_CHARACTER_PREFERENCES)
+ return list()
+ var/list/data = list()
+ var/list/antag_bans = get_antag_bans()
+ if (length(antag_bans))
+ data["antag_bans"] = antag_bans
+ var/list/antag_living_playtime_hours_left = get_antag_living_playtime_hours_left()
+ if (length(antag_living_playtime_hours_left))
+ data["antag_living_playtime_hours_left"] = antag_living_playtime_hours_left
+ return data
+
+/datum/preference_middleware/antags/get_constant_data()
+ var/list/antags = list()
+
+ for(var/pref_type in GLOB.role_preference_entries)
+ var/datum/role_preference/pref = GLOB.role_preference_entries[pref_type]
+ var/datum/antagonist/antag_datum = pref.antag_datum
+ antags += list(list(
+ "name" = pref.name,
+ "description" = pref.description,
+ "category" = pref.category,
+ "per_character" = pref.per_character,
+ "ban_key" = ispath(antag_datum, /datum/antagonist) ? initial(antag_datum.banning_key) : null,
+ "path" = "[pref_type]",
+ "icon_path" = "[serialize_antag_name("[pref.use_icon || pref_type]")]"
+ ))
+
+ return list(
+ "antagonists" = antags,
+ "categories" = GLOB.role_preference_categories,
+ )
+
+/datum/preference_middleware/antags/get_ui_assets()
+ return list(
+ get_asset_datum(/datum/asset/spritesheet/antagonists),
+ )
+
+/datum/preference_middleware/antags/proc/set_antags(list/params, mob/user)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ var/sent_antags = params["antags"]
+ var/toggled = params["toggled"]
+ var/per_character = params["character"]
+
+ var/list/valid_antags = list()
+ for(var/datum/role_preference/type as anything in GLOB.role_preference_entries)
+ if(per_character && !initial(type.per_character))
+ continue
+ valid_antags += "[type]"
+
+ var/any_changed = FALSE
+ for (var/sent_antag in sent_antags)
+ if(!(sent_antag in valid_antags))
+ continue
+ if(per_character)
+ preferences.role_preferences["[sent_antag]"] = toggled
+ else
+ preferences.role_preferences_global["[sent_antag]"] = toggled
+ any_changed = TRUE
+ if(any_changed)
+ if(per_character)
+ preferences.mark_undatumized_dirty_character()
+ else
+ preferences.mark_undatumized_dirty_player()
+ return any_changed
+
+/datum/preference_middleware/antags/proc/get_antag_bans()
+ var/list/antag_bans = list()
+ for(var/type in GLOB.role_preference_entries)
+ var/datum/role_preference/pref = GLOB.role_preference_entries[type]
+ var/datum/antagonist/antag_datum = pref.antag_datum
+ if(!ispath(antag_datum, /datum/antagonist))
+ continue
+ var/role_ban_key = initial(antag_datum.banning_key)
+ if(role_ban_key && is_banned_from(preferences.parent.ckey, role_ban_key))
+ antag_bans += role_ban_key
+ return antag_bans
+
+/datum/preference_middleware/antags/proc/get_antag_living_playtime_hours_left()
+ var/list/antag_living_playtime_hours_left = list()
+
+ for(var/type in GLOB.role_preference_entries)
+ var/datum/role_preference/pref = GLOB.role_preference_entries[type]
+ var/datum/antagonist/antag_datum = pref.antag_datum
+ if(!ispath(antag_datum, /datum/antagonist))
+ continue
+ var/living_hours_needed = initial(antag_datum.required_living_playtime)
+ if (living_hours_needed <= 0)
+ continue
+ var/hours_left = max(0, living_hours_needed - (preferences.parent.get_exp_living(TRUE) / 60))
+ if(hours_left > 0)
+ antag_living_playtime_hours_left["[type]"] = hours_left
+
+ return antag_living_playtime_hours_left
+
+/// Sprites generated for the antagonists panel
+/datum/asset/spritesheet/antagonists
+ name = "antagonists"
+ early = TRUE
+ cross_round_cachable = TRUE
+
+/datum/asset/spritesheet/antagonists/create_spritesheets()
+ var/list/generated_icons = list()
+ var/list/to_insert = list()
+
+ for(var/pref_type in GLOB.role_preference_entries)
+ var/datum/role_preference/pref = GLOB.role_preference_entries[pref_type]
+ if(ispath(pref.use_icon, /datum/role_preference))
+ pref_type = pref.use_icon
+ var/datum/role_preference/other_pref = GLOB.role_preference_entries[pref.use_icon]
+ if(istype(other_pref))
+ pref = other_pref
+
+ // antag_flag is guaranteed to be unique by unit tests.
+ var/spritesheet_key = serialize_antag_name("[pref_type]")
+
+ if (!isnull(generated_icons["[pref_type]"]))
+ to_insert[spritesheet_key] = generated_icons["[pref_type]"]
+ continue
+
+ var/icon/preview_icon = pref.get_preview_icon()
+
+ if (isnull(preview_icon))
+ continue
+
+ // preview_icons are not scaled at this stage INTENTIONALLY.
+ // If an icon is not prepared to be scaled to that size, it looks really ugly, and this
+ // makes it harder to figure out what size it *actually* is.
+ generated_icons["[pref_type]"] = preview_icon
+ to_insert[spritesheet_key] = preview_icon
+
+ for (var/spritesheet_key in to_insert)
+ Insert(spritesheet_key, to_insert[spritesheet_key])
+
+/// Serializes an antag name to be used for preferences UI
+/proc/serialize_antag_name(antag_name)
+ // These are sent through CSS, so they need to be safe to use as class names.
+ return lowertext(sanitize_css_class_name(replacetext(antag_name, "/", "_")))
diff --git a/code/modules/client/preferences/middleware/jobs.dm b/code/modules/client/preferences/middleware/jobs.dm
new file mode 100644
index 0000000000000..428dccd96b3d3
--- /dev/null
+++ b/code/modules/client/preferences/middleware/jobs.dm
@@ -0,0 +1,131 @@
+/datum/preference_middleware/jobs
+ action_delegations = list(
+ "set_job_preference" = PROC_REF(set_job_preference),
+ "clear_job_preferences" = PROC_REF(clear_job_preferences),
+ )
+
+/datum/preference_middleware/jobs/proc/clear_job_preferences(list/params, mob/user)
+ preferences.job_preferences = list()
+ preferences.character_preview_view?.update_body()
+ preferences.mark_undatumized_dirty_character()
+ return TRUE
+
+/datum/preference_middleware/jobs/proc/set_job_preference(list/params, mob/user)
+ var/job_title = params["job"]
+ var/level = params["level"]
+
+ if (level != null && level != JP_LOW && level != JP_MEDIUM && level != JP_HIGH)
+ return FALSE
+
+ var/datum/job/job = SSjob.GetJob(job_title)
+
+ if (isnull(job))
+ return FALSE
+
+ if (job.faction != "Station")
+ return FALSE
+
+ if (!preferences.set_job_preference_level(job, level))
+ return FALSE
+
+ preferences.character_preview_view?.update_body()
+ return TRUE
+
+/datum/preference_middleware/jobs/get_constant_data()
+ var/list/data = list()
+
+ var/list/departments = list()
+ var/list/jobs = list()
+
+ for (var/datum/job/job as anything in SSjob.occupations)
+ if(!job.show_in_prefs)
+ continue
+
+ var/department_flag = job.department_for_prefs
+ if (isnull(department_flag))
+ stack_trace("[job] does not have a department set, yet is a joinable occupation!")
+ continue
+
+ if (isnull(job.description))
+ stack_trace("[job] does not have a description set, yet is a joinable occupation!")
+ continue
+
+ var/department_name = GLOB.dept_bitflag_to_name["[department_flag]"]
+ if (isnull(departments[department_name]))
+ var/department_head_jobname = job.department_head_for_prefs || job.department_head
+ if(islist(department_head_jobname) && length(department_head_jobname))
+ department_head_jobname = department_head_jobname[1]
+ if(length(department_head_jobname))
+ departments[department_name] = list(
+ "head" = department_head_jobname,
+ )
+ else
+ departments[department_name] = list()
+
+ jobs[job.title] = list(
+ "description" = job.description,
+ "department" = department_name,
+ )
+
+ data["departments"] = departments
+ data["jobs"] = jobs
+
+ return data
+
+/datum/preference_middleware/jobs/get_ui_data(mob/user)
+ var/list/data = list()
+
+ data["job_preferences"] = preferences.job_preferences
+
+ return data
+
+/datum/preference_middleware/jobs/get_ui_static_data(mob/user)
+ var/list/data = list()
+
+ var/list/required_job_playtime = get_required_job_playtime(user)
+ if (!isnull(required_job_playtime))
+ data += required_job_playtime
+
+ var/list/job_bans = get_job_bans(user)
+ if (job_bans.len)
+ data["job_bans"] = job_bans
+
+ return data.len > 0 ? data : null
+
+/datum/preference_middleware/jobs/proc/get_required_job_playtime(mob/user)
+ var/list/data = list()
+
+ var/list/job_days_left = list()
+ var/list/job_required_experience = list()
+
+ for (var/datum/job/job as anything in SSjob.occupations)
+ if(!job.show_in_prefs)
+ continue
+ var/required_playtime_remaining = job.required_playtime_remaining(user.client)
+ if (required_playtime_remaining)
+ job_required_experience[job.title] = list(
+ "experience_type" = job.get_exp_req_type(),
+ "required_playtime" = required_playtime_remaining,
+ )
+
+ continue
+
+ if (!job.player_old_enough(user.client))
+ job_days_left[job.title] = job.available_in_days(user.client)
+
+ if (job_days_left.len)
+ data["job_days_left"] = job_days_left
+
+ if (job_required_experience)
+ data["job_required_experience"] = job_required_experience
+
+ return data
+
+/datum/preference_middleware/jobs/proc/get_job_bans(mob/user)
+ var/list/data = list()
+
+ for (var/datum/job/job as anything in SSjob.occupations)
+ if (is_banned_from(user.client?.ckey, job.title))
+ data += job.title
+
+ return data
diff --git a/code/modules/client/preferences/middleware/keybindings.dm b/code/modules/client/preferences/middleware/keybindings.dm
new file mode 100644
index 0000000000000..56910ee311e3a
--- /dev/null
+++ b/code/modules/client/preferences/middleware/keybindings.dm
@@ -0,0 +1,95 @@
+/// Number of unique keycombos allowed to be bound to one keybinding
+#define MAX_HOTKEY_SLOTS 3
+
+/// Middleware to handle keybindings
+/datum/preference_middleware/keybindings
+ action_delegations = list(
+ "reset_all_keybinds" = PROC_REF(reset_all_keybinds),
+ "reset_keybinds_to_defaults" = PROC_REF(reset_keybinds_to_defaults),
+ "set_keybindings" = PROC_REF(set_keybindings),
+ )
+
+/datum/preference_middleware/keybindings/get_ui_static_data(mob/user)
+ if (preferences.current_window == PREFERENCE_TAB_CHARACTER_PREFERENCES)
+ return list()
+
+ var/list/keybindings = preferences.key_bindings
+
+ return list(
+ "keybindings" = keybindings,
+ )
+
+/datum/preference_middleware/keybindings/get_ui_assets()
+ return list(
+ get_asset_datum(/datum/asset/json/keybindings)
+ )
+
+/datum/preference_middleware/keybindings/proc/reset_all_keybinds(list/params, mob/user)
+ preferences.set_default_key_bindings(save = TRUE)
+ preferences.update_static_data(user)
+
+ return TRUE
+
+/datum/preference_middleware/keybindings/proc/reset_keybinds_to_defaults(list/params, mob/user)
+ var/keybind_name = params["keybind_name"]
+ var/datum/keybinding/keybinding = GLOB.keybindings_by_name[keybind_name]
+
+ if (isnull(keybinding))
+ return FALSE
+
+ preferences.key_bindings[keybind_name] = keybinding.keys
+
+ preferences.update_static_data(user)
+ preferences.mark_undatumized_dirty_player()
+
+ return TRUE
+
+/datum/preference_middleware/keybindings/proc/set_keybindings(list/params)
+ var/keybind_name = params["keybind_name"]
+
+ if (isnull(GLOB.keybindings_by_name[keybind_name]))
+ return FALSE
+
+ var/list/raw_hotkeys = params["hotkeys"]
+ if (!istype(raw_hotkeys))
+ return FALSE
+
+ if (raw_hotkeys.len > MAX_HOTKEY_SLOTS)
+ return FALSE
+
+ // There's no optimal, easy way to check if something is an array
+ // and not an object in BYOND, so just sanitize it to make sure.
+ var/list/hotkeys = list()
+ for (var/hotkey in raw_hotkeys)
+ if (!istext(hotkey))
+ return FALSE
+
+ // Fairly arbitrary number, it's just so you don't save enormous fake keybinds.
+ if (length(hotkey) > 100)
+ return FALSE
+
+ hotkeys += hotkey
+
+ preferences.set_keybind(keybind_name, hotkeys)
+ return TRUE
+
+/datum/asset/json/keybindings
+ name = "keybindings"
+
+/datum/asset/json/keybindings/generate()
+ var/list/keybindings = list()
+
+ for (var/name in GLOB.keybindings_by_name)
+ var/datum/keybinding/keybinding = GLOB.keybindings_by_name[name]
+
+ if (!(keybinding.category in keybindings))
+ keybindings[keybinding.category] = list()
+
+ keybindings[keybinding.category][keybinding.name] = list(
+ "name" = keybinding.full_name,
+ "description" = keybinding.description,
+ )
+
+ return keybindings
+
+#undef MAX_HOTKEY_SLOTS
diff --git a/code/modules/client/preferences/middleware/loadout.dm b/code/modules/client/preferences/middleware/loadout.dm
new file mode 100644
index 0000000000000..aab738899f3ec
--- /dev/null
+++ b/code/modules/client/preferences/middleware/loadout.dm
@@ -0,0 +1,95 @@
+/datum/preference_middleware/loadout
+ action_delegations = list(
+ "purchase_gear" = PROC_REF(purchase_gear),
+ "equip_gear" = PROC_REF(equip_gear),
+ )
+
+/datum/preference_middleware/loadout/get_ui_data(mob/user)
+ var/list/data = list()
+ data["equipped_gear"] = preferences.equipped_gear
+ data["purchased_gear"] = preferences.purchased_gear
+ data["metacurrency_balance"] = preferences.parent.get_metabalance_unreliable()
+ data["is_donator"] = (IS_PATRON(preferences.parent.ckey) || is_admin(preferences.parent))
+ return data
+
+/datum/preference_middleware/loadout/get_constant_data()
+ var/list/data = list()
+ var/list/categories = list()
+ for(var/category_id in GLOB.loadout_categories)
+ var/datum/loadout_category/LC = GLOB.loadout_categories[category_id]
+ if(LC.category == "Donator" && !CONFIG_GET(flag/donator_items)) // Don't show donator items if the server has them off
+ continue
+ var/list/category = list()
+ category["name"] = LC.category
+ var/list/gear = list()
+ for(var/gear_id in LC.gear)
+ var/datum/gear/G = LC.gear[gear_id]
+ var/list/gear_entry = list()
+ gear_entry["id"] = G.id
+ gear_entry["display_name"] = G.display_name
+ gear_entry["skirt_display_name"] = G.skirt_display_name
+ gear_entry["donator"] = G.sort_category == "Donator"
+ gear_entry["cost"] = G.cost
+ gear_entry["description"] = G.description
+ gear_entry["skirt_description"] = G.skirt_description
+ gear_entry["allowed_roles"] = G.allowed_roles
+ gear_entry["is_equippable"] = G.is_equippable
+ gear_entry["multi_purchase"] = G.multi_purchase
+ gear += list(gear_entry)
+ category["gear"] = gear
+ categories += list(category)
+ data["categories"] = categories
+ data["metacurrency_name"] = CONFIG_GET(string/metacurrency_name)
+ return data
+
+/datum/preference_middleware/loadout/proc/purchase_gear(list/params, mob/user)
+ var/datum/gear/TG = GLOB.gear_datums[params["id"]]
+ if(!istype(TG))
+ return
+ if(((TG.id in preferences.purchased_gear) || (TG.id in preferences.equipped_gear)) && !TG.multi_purchase)
+ to_chat(user, "You already own \the [TG.display_name]!")
+ return TRUE
+ if(TG.sort_category == "Donator")
+ if(user.client && CONFIG_GET(flag/donator_items) && alert(user.client, "This item is only accessible to our patrons. Would you like to subscribe?", "Patron Locked", "Yes", "No") == "Yes")
+ user.client.donate()
+ return
+
+ if(TG.cost <= user.client.get_metabalance_db())
+ preferences.purchased_gear += TG.id
+ TG.purchase(user.client)
+ user.client.inc_metabalance((TG.cost * -1), TRUE, "Purchased [TG.display_name].")
+ preferences.mark_undatumized_dirty_player()
+ return TRUE
+ else
+ to_chat(user, "You don't have enough [CONFIG_GET(string/metacurrency_name)]s to purchase \the [TG.display_name]!")
+
+/datum/preference_middleware/loadout/proc/equip_gear(list/params, mob/user)
+ var/datum/gear/TG = GLOB.gear_datums[params["id"]]
+ if(!istype(TG))
+ return
+ if(TG.id in preferences.equipped_gear)
+ preferences.equipped_gear -= TG.id
+ preferences.character_preview_view?.update_body()
+ preferences.mark_undatumized_dirty_character()
+ return TRUE
+ else
+ var/list/type_blacklist = list()
+ var/list/slot_blacklist = list()
+ for(var/gear_id in preferences.equipped_gear)
+ var/datum/gear/G = GLOB.gear_datums[gear_id]
+ if(istype(G))
+ if(!(G.subtype_path in type_blacklist))
+ type_blacklist += G.subtype_path
+ if(!(G.slot in slot_blacklist))
+ slot_blacklist += G.slot
+ if((TG.id in preferences.purchased_gear))
+ if(!(TG.subtype_path in type_blacklist) || !(TG.slot in slot_blacklist))
+ preferences.equipped_gear += TG.id
+ preferences.character_preview_view?.update_body()
+ preferences.mark_undatumized_dirty_character()
+ return TRUE
+ else
+ to_chat(user, "Can't equip [TG.display_name]. It conflicts with an already-equipped item.")
+ else
+ log_href_exploit(user, "Attempting to equip [TG.type] when they do not own it.")
+ return TRUE
diff --git a/code/modules/client/preferences/middleware/names.dm b/code/modules/client/preferences/middleware/names.dm
new file mode 100644
index 0000000000000..4f9e716a2e304
--- /dev/null
+++ b/code/modules/client/preferences/middleware/names.dm
@@ -0,0 +1,56 @@
+/// Middleware that handles telling the UI which name to show, and waht names
+/// they have.
+/datum/preference_middleware/names
+ action_delegations = list(
+ "randomize_name" = PROC_REF(randomize_name),
+ )
+
+/datum/preference_middleware/names/get_constant_data()
+ var/list/data = list()
+
+ var/list/types = list()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/name/name_preference = GLOB.preference_entries[preference_type]
+ if (!istype(name_preference))
+ continue
+
+ types[name_preference.db_key] = list(
+ "can_randomize" = name_preference.is_randomizable(),
+ "explanation" = name_preference.explanation,
+ "group" = name_preference.group,
+ )
+
+ data["types"] = types
+
+ return data
+
+/datum/preference_middleware/names/get_ui_data(mob/user)
+ var/list/data = list()
+
+ data["name_to_use"] = get_name_to_use()
+
+ return data
+
+/datum/preference_middleware/names/proc/get_name_to_use()
+ var/highest_priority_job = preferences.get_highest_priority_job()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/name/name_preference = GLOB.preference_entries[preference_type]
+ if (!istype(name_preference))
+ continue
+
+ if (isnull(name_preference.relevant_job))
+ continue
+
+ if (istype(highest_priority_job, name_preference.relevant_job))
+ return name_preference.db_key
+
+ return "real_name"
+
+/datum/preference_middleware/names/proc/randomize_name(list/params, mob/user)
+ var/datum/preference/name/name_preference = GLOB.preference_entries_by_key[params["preference"]]
+ if (!istype(name_preference))
+ return FALSE
+
+ return preferences.update_preference(name_preference, name_preference.create_random_value(preferences), in_menu = TRUE)
diff --git a/code/modules/client/preferences/middleware/quirks.dm b/code/modules/client/preferences/middleware/quirks.dm
new file mode 100644
index 0000000000000..874afb70103d7
--- /dev/null
+++ b/code/modules/client/preferences/middleware/quirks.dm
@@ -0,0 +1,92 @@
+/// Middleware to handle quirks
+
+/datum/preference_middleware/quirks
+ var/tainted = FALSE
+
+ action_delegations = list(
+ "give_quirk" = PROC_REF(give_quirk),
+ "remove_quirk" = PROC_REF(remove_quirk),
+ )
+
+/datum/preference_middleware/quirks/get_ui_static_data(mob/user)
+ if (preferences.current_window != PREFERENCE_TAB_CHARACTER_PREFERENCES)
+ return list()
+
+ var/list/data = list()
+
+ data["selected_quirks"] = get_selected_quirks()
+
+ return data
+
+/datum/preference_middleware/quirks/get_ui_data(mob/user)
+ var/list/data = list()
+
+ if (tainted)
+ tainted = FALSE
+ data["selected_quirks"] = get_selected_quirks()
+
+ return data
+
+/datum/preference_middleware/quirks/get_constant_data()
+ var/list/quirk_info = list()
+
+ var/list/quirks = SSquirks.get_quirks()
+
+ for (var/quirk_name in quirks)
+ var/datum/quirk/quirk = quirks[quirk_name]
+ quirk_info[sanitize_css_class_name(quirk_name)] = list(
+ "description" = initial(quirk.desc),
+ "icon" = initial(quirk.icon),
+ "name" = quirk_name,
+ "value" = initial(quirk.value),
+ )
+
+ return list(
+ "max_positive_quirks" = MAX_QUIRKS,
+ "quirk_info" = quirk_info,
+ "quirk_blacklist" = SSquirks.quirk_blacklist,
+ )
+
+/datum/preference_middleware/quirks/on_new_character(mob/user)
+ tainted = TRUE
+
+/datum/preference_middleware/quirks/proc/give_quirk(list/params, mob/user)
+ var/quirk_name = params["quirk"]
+
+ var/list/new_quirks = preferences.all_quirks | quirk_name
+ if (SSquirks.filter_invalid_quirks(new_quirks) != new_quirks)
+ // If the client is sending an invalid give_quirk, that means that
+ // something went wrong with the client prediction, so we should
+ // catch it back up to speed.
+ preferences.update_static_data(user)
+ return TRUE
+
+ preferences.all_quirks = new_quirks
+ preferences.mark_undatumized_dirty_character()
+ return TRUE
+
+/datum/preference_middleware/quirks/proc/remove_quirk(list/params, mob/user)
+ var/quirk_name = params["quirk"]
+
+ var/list/new_quirks = preferences.all_quirks - quirk_name
+ if ( \
+ !(quirk_name in preferences.all_quirks) \
+ || SSquirks.filter_invalid_quirks(new_quirks) != new_quirks \
+ )
+ // If the client is sending an invalid remove_quirk, that means that
+ // something went wrong with the client prediction, so we should
+ // catch it back up to speed.
+ preferences.update_static_data(user)
+ return TRUE
+
+ preferences.all_quirks = new_quirks
+ preferences.mark_undatumized_dirty_character()
+ return TRUE
+
+/datum/preference_middleware/quirks/proc/get_selected_quirks()
+ var/list/selected_quirks = list()
+
+ for (var/quirk in preferences.all_quirks)
+ selected_quirks += sanitize_css_class_name(quirk)
+
+ return selected_quirks
diff --git a/code/modules/client/preferences/middleware/random.dm b/code/modules/client/preferences/middleware/random.dm
new file mode 100644
index 0000000000000..eca9d1cdaddbc
--- /dev/null
+++ b/code/modules/client/preferences/middleware/random.dm
@@ -0,0 +1,84 @@
+/// Middleware for handling randomization preferences
+/datum/preference_middleware/random
+ action_delegations = list(
+ "randomize_character" = PROC_REF(randomize_character),
+ "set_random_preference" = PROC_REF(set_random_preference),
+ )
+
+/datum/preference_middleware/random/get_character_preferences(mob/user)
+ return list(
+ "randomization" = preferences.randomise,
+ )
+
+/datum/preference_middleware/random/get_constant_data()
+ var/list/randomizable = list()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (!preference.is_randomizable())
+ continue
+
+ randomizable += preference.db_key
+
+ return list(
+ "randomizable" = randomizable,
+ )
+
+/datum/preference_middleware/random/proc/randomize_character()
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (preferences.should_randomize(preference))
+ preferences.write_preference(preference, preference.create_random_value(preferences))
+
+ preferences.character_preview_view.update_body()
+
+ return TRUE
+
+/datum/preference_middleware/random/proc/set_random_preference(list/params, mob/user)
+ var/requested_preference_key = params["preference"]
+ var/value = params["value"]
+
+ var/datum/preference/requested_preference = GLOB.preference_entries_by_key[requested_preference_key]
+ if (isnull(requested_preference))
+ return FALSE
+
+ if (!requested_preference.is_randomizable())
+ return FALSE
+
+ if (value == RANDOM_ANTAG_ONLY)
+ preferences.randomise[requested_preference_key] = RANDOM_ANTAG_ONLY
+ else if (value == RANDOM_ENABLED)
+ preferences.randomise[requested_preference_key] = RANDOM_ENABLED
+ else if (value == RANDOM_DISABLED)
+ preferences.randomise -= requested_preference_key
+ else
+ return FALSE
+ preferences.mark_undatumized_dirty_character()
+ return TRUE
+
+/// Returns if a preference should be randomized.
+/datum/preferences/proc/should_randomize(datum/preference/preference, is_antag)
+ if (!preference.is_randomizable())
+ return FALSE
+
+ var/requested_randomization = randomise[preference.db_key]
+
+ if (istype(preference, /datum/preference/name))
+ requested_randomization = read_character_preference(/datum/preference/choiced/random_name)
+
+ switch (requested_randomization)
+ if (RANDOM_ENABLED)
+ return TRUE
+ if (RANDOM_ANTAG_ONLY)
+ return is_antag
+ else
+ return FALSE
+
+/// Given randomization flags, will return whether or not this preference should be randomized.
+/datum/preference/proc/included_in_randomization_flags(randomize_flags)
+ return TRUE
+
+/datum/preference/name/included_in_randomization_flags(randomize_flags)
+ return !!(randomize_flags & RANDOMIZE_NAME)
+
+/datum/preference/choiced/species/included_in_randomization_flags(randomize_flags)
+ return !!(randomize_flags & RANDOMIZE_SPECIES)
diff --git a/code/modules/client/preferences/middleware/species.dm b/code/modules/client/preferences/middleware/species.dm
new file mode 100644
index 0000000000000..02efe1e223a5b
--- /dev/null
+++ b/code/modules/client/preferences/middleware/species.dm
@@ -0,0 +1,35 @@
+/// Handles the assets for species icons
+/datum/preference_middleware/species
+
+/datum/preference_middleware/species/get_ui_assets()
+ return list(
+ get_asset_datum(/datum/asset/spritesheet/species),
+ )
+
+/datum/asset/spritesheet/species
+ name = "species"
+ early = TRUE
+ cross_round_cachable = TRUE
+
+/datum/asset/spritesheet/species/create_spritesheets()
+ var/list/to_insert = list()
+
+ for (var/species_id in get_selectable_species())
+ var/datum/species/species_type = GLOB.species_list[species_id]
+
+ var/mob/living/carbon/human/dummy/consistent/dummy = new
+ dummy.set_species(species_type)
+ dummy.equipOutfit(/datum/outfit/job/assistant/consistent, visualsOnly = TRUE)
+ dummy.dna.species.prepare_human_for_preview(dummy)
+ COMPILE_OVERLAYS(dummy)
+
+ var/icon/dummy_icon = getFlatIcon(dummy)
+ dummy_icon.Scale(64, 64)
+ dummy_icon.Crop(15, 64, 15 + 31, 64 - 31)
+ dummy_icon.Scale(64, 64)
+ to_insert[sanitize_css_class_name(initial(species_type.name))] = dummy_icon
+
+ SSatoms.prepare_deletion(dummy)
+
+ for (var/spritesheet_key in to_insert)
+ Insert(spritesheet_key, to_insert[spritesheet_key])
diff --git a/code/modules/client/preferences/preference_entry.dm b/code/modules/client/preferences/preference_entry.dm
new file mode 100644
index 0000000000000..68688954875fe
--- /dev/null
+++ b/code/modules/client/preferences/preference_entry.dm
@@ -0,0 +1,553 @@
+// Priorities must be in order!
+/// The default priority level
+#define PREFERENCE_PRIORITY_DEFAULT 1
+
+/// The priority at which species runs, needed for external organs to apply properly.
+#define PREFERENCE_PRIORITY_SPECIES 2
+
+/// The priority at which gender is determined, needed for proper randomization.
+#define PREFERENCE_PRIORITY_GENDER 3
+
+/// The priority at which body model is decided, applied after gender so we can
+/// make sure they're non-binary.
+#define PREFERENCE_PRIORITY_BODY_MODEL 4
+
+/// The priority at which eye color is applied, needed so IPCs get the right screen color.
+#define PREFERENCE_PRIORITY_EYE_COLOR 5
+
+/// The priority at which names are decided, needed for proper randomization.
+#define PREFERENCE_PRIORITY_NAMES 6
+
+/// The maximum preference priority, keep this updated, but don't use it for `priority`.
+#define MAX_PREFERENCE_PRIORITY PREFERENCE_PRIORITY_NAMES
+
+/// For choiced preferences, this key will be used to set display names in constant data.
+#define CHOICED_PREFERENCE_DISPLAY_NAMES "display_names"
+
+/// For main feature preferences, this key refers to a feature considered supplemental.
+/// For instance, hair color being supplemental to hair.
+#define SUPPLEMENTAL_FEATURE_KEY "supplemental_feature"
+
+/// An assoc list list of types to instantiated `/datum/preference` instances
+GLOBAL_LIST_INIT(preference_entries, init_preference_entries())
+
+/// An assoc list of preference entries by their `db_key`
+GLOBAL_LIST_INIT(preference_entries_by_key, init_preference_entries_by_key())
+
+/proc/init_preference_entries()
+ var/list/output = list()
+ for (var/datum/preference/preference_type as anything in subtypesof(/datum/preference))
+ if (initial(preference_type.abstract_type) == preference_type)
+ continue
+ output[preference_type] = new preference_type
+ return output
+
+/proc/init_preference_entries_by_key()
+ var/list/output = list()
+ for (var/datum/preference/preference_type as anything in subtypesof(/datum/preference))
+ if (initial(preference_type.abstract_type) == preference_type)
+ continue
+ output[initial(preference_type.db_key)] = GLOB.preference_entries[preference_type]
+ return output
+
+/// Returns a flat list of preferences in order of their priority
+/proc/get_preferences_in_priority_order()
+ var/list/preferences[MAX_PREFERENCE_PRIORITY]
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ LAZYADD(preferences[preference.priority], preference)
+
+ var/list/flattened = list()
+ for (var/index in 1 to MAX_PREFERENCE_PRIORITY)
+ flattened += preferences[index]
+ return flattened
+
+/// Represents an individual preference.
+/datum/preference
+ /// The key inside the database to use.
+ /// This is also sent to the UI.
+ /// Once you pick this, don't change it.
+ var/db_key
+
+ /// The category of preference, for use by the PreferencesMenu.
+ /// This isn't used for anything other than as a key for UI data.
+ /// It is up to the PreferencesMenu UI itself to interpret it.
+ var/category = "misc"
+
+ /// Do not instantiate if type matches this.
+ var/abstract_type = /datum/preference
+
+ /// What location should this preference be read from?
+ /// Valid values are PREFERENCE_CHARACTER and PREFERENCE_PLAYER.
+ /// See the documentation in [code/__DEFINES/preferences.dm].
+ var/preference_type
+
+ /// The priority of when to apply this preference.
+ /// Used for when you need to rely on another preference.
+ var/priority = PREFERENCE_PRIORITY_DEFAULT
+
+ /// If set, will be available to randomize, but only if the preference
+ /// is for PREFERENCE_CHARACTER.
+ var/can_randomize = TRUE
+
+ /// If randomizable (PREFERENCE_CHARACTER and can_randomize), whether
+ /// or not to enable randomization by default.
+ /// This doesn't mean it'll always be random, but rather if a player
+ /// DOES have random body on, will this already be randomized?
+ var/randomize_by_default = TRUE
+
+ /// If the selected species has this in its /datum/species/mutant_bodyparts,
+ /// will show the feature as selectable.
+ var/relevant_mutant_bodypart = null
+
+ /// If the selected species has this in its /datum/species/species_traits,
+ /// will show the feature as selectable.
+ var/relevant_species_trait = null
+
+ /// If this requires create_informed_default_value
+ var/informed = FALSE
+
+/// Called on the saved input when retrieving.
+/// Also called by the value sent from the user through UI. Do not trust it.
+/// Input is the value inside the database, output is to tell other code
+/// what the value is.
+/// This is useful either for more optimal data saving or for migrating
+/// older data.
+/// Must be overridden by subtypes.
+/// Can return null if no value was found.
+/datum/preference/proc/deserialize(input, datum/preferences/preferences)
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`deserialize()` was not implemented on [type]!")
+
+/// Called on the input while saving.
+/// Input is the current value, output is what to save in the database.
+/// For PREFERENCE_PLAYER, this will be to a string, for PREFERENCE_CHARACTER, it varies
+/datum/preference/proc/serialize(input)
+ SHOULD_NOT_SLEEP(TRUE)
+ return input
+
+/// Produce a default, potentially random value for when no value for this
+/// preference is found in the database.
+/// Either this or create_informed_default_value must be overriden by subtypes.
+/// For PREFERENCE_PLAYER, this will be from a string, for PREFERENCE_CHARACTER, it varies
+/datum/preference/proc/create_default_value()
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`create_default_value()` was not implemented on [type]!")
+
+/// Produce a default, potentially random value for when no value for this
+/// preference is found in the database.
+/// Unlike create_default_value(), will provide the preferences object if you
+/// need to use it.
+/// If not overriden, will call create_default_value() instead.
+/datum/preference/proc/create_informed_default_value(datum/preferences/preferences)
+ return create_default_value()
+
+/// Produce a random value for the purposes of character randomization.
+/// Will just create a default value by default.
+/datum/preference/proc/create_random_value(datum/preferences/preferences)
+ return create_informed_default_value(preferences)
+
+/// Returns whether or not a preference can be randomized.
+/datum/preference/proc/is_randomizable()
+ SHOULD_NOT_OVERRIDE(TRUE)
+ return preference_type == PREFERENCE_CHARACTER && can_randomize
+
+/// Apply this preference onto the given client.
+/// Called when the preference_type == PREFERENCE_PLAYER.
+/datum/preference/proc/apply_to_client(client/client, value)
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ return
+
+/// Fired when the preference is updated.
+/// Calls apply_to_client by default, but can be overridden.
+/datum/preference/proc/apply_to_client_updated(client/client, value)
+ SHOULD_NOT_SLEEP(TRUE)
+ apply_to_client(client, value)
+
+/// Apply this preference onto the given human.
+/// Must be overriden by subtypes.
+/// Called when the preference_type == PREFERENCE_CHARACTER.
+/datum/preference/proc/apply_to_human(mob/living/carbon/human/target, value)
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`apply_to_human()` was not implemented for [type]!")
+
+/datum/preferences/proc/get_preference_holder(datum/preference/preference_entry)
+ RETURN_TYPE(/datum/preferences_holder)
+ if(preference_entry.preference_type == PREFERENCE_CHARACTER)
+ return character_data
+ return player_data
+
+/// Read a /datum/preference type and return its value, only using cached values and queueing any necessary writes.
+/datum/preferences/proc/read_preference(preference_typepath)
+ var/datum/preference/preference_entry = GLOB.preference_entries[preference_typepath]
+ if (isnull(preference_entry))
+ var/extra_info = ""
+
+ // Current initializing subsystem is important to know because it might be a problem with
+ // things running pre-assets-initialization.
+ if (!isnull(Master.current_initializing_subsystem))
+ extra_info = "Info was attempted to be retrieved while [Master.current_initializing_subsystem] was initializing."
+
+ CRASH("Preference type `[preference_typepath]` is invalid! [extra_info]")
+ return get_preference_holder(preference_entry).read_preference(src, preference_entry)
+
+/// Read a /datum/preference type and return its value, only using cached values and queueing any necessary writes.
+/// Only works for player preferences.
+/datum/preferences/proc/read_player_preference(preference_typepath)
+ var/datum/preference/preference_entry = GLOB.preference_entries[preference_typepath]
+ if (isnull(preference_entry))
+ var/extra_info = ""
+
+ // Current initializing subsystem is important to know because it might be a problem with
+ // things running pre-assets-initialization.
+ if (!isnull(Master.current_initializing_subsystem))
+ extra_info = "Info was attempted to be retrieved while [Master.current_initializing_subsystem] was initializing."
+
+ CRASH("Preference type `[preference_typepath]` is invalid! [extra_info]")
+
+ if (preference_entry.preference_type == PREFERENCE_CHARACTER)
+ CRASH("read_player_preference called on PREFERENCE_CHARACTER type preference [preference_typepath].")
+
+ return player_data.read_preference(src, preference_entry)
+
+/// Read a /datum/preference type and return its value, only using cached values and queueing any necessary writes.
+/// Only works for character preferences.
+/datum/preferences/proc/read_character_preference(preference_typepath)
+ var/datum/preference/preference_entry = GLOB.preference_entries[preference_typepath]
+ if (isnull(preference_entry))
+ var/extra_info = ""
+
+ // Current initializing subsystem is important to know because it might be a problem with
+ // things running pre-assets-initialization.
+ if (!isnull(Master.current_initializing_subsystem))
+ extra_info = "Info was attempted to be retrieved while [Master.current_initializing_subsystem] was initializing."
+
+ CRASH("Preference type `[preference_typepath]` is invalid! [extra_info]")
+
+ if (preference_entry.preference_type == PREFERENCE_PLAYER)
+ CRASH("read_character_preference called on PREFERENCE_PLAYER type preference [preference_typepath].")
+
+ return character_data.read_preference(src, preference_entry)
+
+/// Set a /datum/preference entry.
+/// Returns TRUE for a successful preference application.
+/// Returns FALSE if it is invalid.
+/datum/preferences/proc/write_preference(datum/preference/preference, preference_value)
+ return get_preference_holder(preference).write_preference(src, preference, preference_value)
+
+/// Will perform a write on the preference and update the relevant locations.
+/// This will, for instance, update the character preference view.
+/// Performs sanity checks.
+/datum/preferences/proc/update_preference(preference_or_typepath, preference_value, in_menu = FALSE)
+ var/datum/preference/preference
+ if (ispath(preference_or_typepath, /datum/preference))
+ preference = GLOB.preference_entries[preference_or_typepath]
+ else if (istype(preference_or_typepath, /datum/preference))
+ preference = preference_or_typepath
+ if (isnull(preference))
+ CRASH("Preference type `[preference_or_typepath]` is invalid!")
+
+ if (!preference.is_accessible(src, ignore_page = !in_menu))
+ return FALSE
+
+ write_preference(preference, preference_value)
+
+ if (preference.preference_type == PREFERENCE_PLAYER)
+ preference.apply_to_client_updated(parent, read_preference(preference.type))
+ else
+ character_preview_view?.update_body()
+
+ // A non-preference menu source changed a preference. We should send new preferences now.
+ if(!in_menu)
+ ui_update()
+
+ return TRUE
+
+/// Checks that a given value is valid.
+/// Must be overriden by subtypes.
+/// Any type can be passed through.
+/datum/preference/proc/is_valid(value)
+ SHOULD_NOT_SLEEP(TRUE)
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`is_valid()` was not implemented for [type]!")
+
+/// Returns data to be sent to users in the menu
+/datum/preference/proc/compile_ui_data(mob/user, value)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ return serialize(value)
+
+/// Returns data compiled into the preferences JSON asset
+/datum/preference/proc/compile_constant_data()
+ SHOULD_NOT_SLEEP(TRUE)
+
+ return null
+
+/// Returns whether or not this preference is accessible.
+/// If FALSE, will not show in the UI and will not be editable (by update_preference).
+/datum/preference/proc/is_accessible(datum/preferences/preferences, ignore_page = FALSE)
+ SHOULD_CALL_PARENT(TRUE)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ if (!isnull(relevant_mutant_bodypart) || !isnull(relevant_species_trait))
+ var/species_type = preferences.read_character_preference(/datum/preference/choiced/species)
+
+ var/datum/species/species = new species_type
+ if (!(db_key in species.get_features()))
+ return FALSE
+
+ if (!ignore_page && !should_show_on_page(preferences.current_window))
+ return FALSE
+
+ return TRUE
+
+/// Returns whether or not, given the PREFERENCE_TAB_*, this preference should
+/// appear.
+/datum/preference/proc/should_show_on_page(preference_tab)
+ var/is_on_character_page = preference_tab == PREFERENCE_TAB_CHARACTER_PREFERENCES
+ var/is_character_preference = preference_type == PREFERENCE_CHARACTER
+ return is_on_character_page == is_character_preference
+
+/// A preference that is a choice of one option among a fixed set.
+/// Used for preferences such as clothing.
+/datum/preference/choiced
+ /// If this is TRUE, icons will be generated.
+ /// This is necessary for if your `init_possible_values()` override
+ /// returns an assoc list of names to atoms/icons.
+ var/should_generate_icons = FALSE
+
+ var/list/cached_values
+
+ /// If the preference is a main feature (PREFERENCE_CATEGORY_FEATURES or PREFERENCE_CATEGORY_CLOTHING)
+ /// this is the name of the feature that will be presented.
+ var/main_feature_name
+
+ /// Which spritesheet this preference should go on. This is used for particularly massive choice lists to reduce mount lag.
+ var/preference_spritesheet = PREFERENCE_SHEET_NORMAL
+
+ abstract_type = /datum/preference/choiced
+
+/// Returns a list of every possible value.
+/// The first time this is called, will run `init_values()`.
+/// Return value can be in the form of:
+/// - A flat list of raw values, such as list(MALE, FEMALE, PLURAL).
+/// - An assoc list of raw values to atoms/icons.
+/datum/preference/choiced/proc/get_choices()
+ // Override `init_values()` instead.
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ if (isnull(cached_values))
+ cached_values = init_possible_values()
+ ASSERT(cached_values.len)
+
+ return cached_values
+
+/// Returns a list of every possible value, serialized.
+/// Return value can be in the form of:
+/// - A flat list of serialized values, such as list(MALE, FEMALE, PLURAL).
+/// - An assoc list of serialized values to atoms/icons.
+/datum/preference/choiced/proc/get_choices_serialized()
+ // Override `init_values()` instead.
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ var/list/serialized_choices = list()
+ var/choices = get_choices()
+
+ if (should_generate_icons)
+ for (var/choice in choices)
+ serialized_choices[serialize(choice)] = choices[choice]
+ else
+ for (var/choice in choices)
+ serialized_choices += serialize(choice)
+
+ return serialized_choices
+
+/// Returns a list of every possible value.
+/// This must be overriden by `/datum/preference/choiced` subtypes.
+/// Return value can be in the form of:
+/// - A flat list of raw values, such as list(MALE, FEMALE, PLURAL).
+/// - An assoc list of raw values to atoms/icons, in which case
+/// icons will be generated.
+/datum/preference/choiced/proc/init_possible_values()
+ CRASH("`init_possible_values()` was not implemented for [type]!")
+
+/datum/preference/choiced/is_valid(value)
+ return value in get_choices()
+
+/datum/preference/choiced/deserialize(input, datum/preferences/preferences)
+ return sanitize_inlist(input, get_choices(), create_default_value())
+
+/datum/preference/choiced/create_default_value()
+ return pick(get_choices())
+
+/datum/preference/choiced/compile_constant_data()
+ var/list/data = list()
+
+ var/list/choices = list()
+
+ for (var/choice in get_choices())
+ choices += choice
+
+ data["choices"] = choices
+
+ if (should_generate_icons)
+ var/list/icons = list()
+
+ for (var/choice in choices)
+ icons[choice] = get_spritesheet_key(choice)
+
+ data["icons"] = icons
+ data["icon_sheet"] = preference_spritesheet
+
+ if (!isnull(main_feature_name))
+ data["name"] = main_feature_name
+
+ return data
+
+/// A preference that represents an RGB color of something, crunched down to 3 hex numbers.
+/// Was used heavily in the past, but doesn't provide as much range and only barely conserves space.
+/datum/preference/color_legacy
+ abstract_type = /datum/preference/color_legacy
+
+/datum/preference/color_legacy/deserialize(input, datum/preferences/preferences)
+ return sanitize_hexcolor(input)
+
+/datum/preference/color_legacy/create_default_value()
+ return random_short_color()
+
+/datum/preference/color_legacy/is_valid(value)
+ var/static/regex/is_legacy_color = regex(@"^[0-9a-fA-F]{3}$")
+ return findtext(value, is_legacy_color)
+
+/// A preference that represents an RGB color of something.
+/// Will give the value as 6 hex digits, without a hash.
+/datum/preference/color
+ abstract_type = /datum/preference/color
+
+/datum/preference/color/deserialize(input, datum/preferences/preferences)
+ return sanitize_hexcolor(input, desired_format = 6, include_crunch = TRUE)
+
+/datum/preference/color/create_default_value()
+ return random_color()
+
+/datum/preference/color/serialize(input)
+ return sanitize_hexcolor(input, desired_format = 6, include_crunch = TRUE)
+
+/datum/preference/color/is_valid(value)
+ return findtext(value, GLOB.is_color)
+
+/// Takes an assoc list of names to /datum/sprite_accessory and returns a value
+/// fit for `/datum/preference/init_possible_values()`
+/proc/possible_values_for_sprite_accessory_list(list/datum/sprite_accessory/sprite_accessories)
+ var/list/possible_values = list()
+ for (var/name in sprite_accessories)
+ var/datum/sprite_accessory/sprite_accessory = sprite_accessories[name]
+ if (istype(sprite_accessory))
+ possible_values[name] = icon(sprite_accessory.icon, sprite_accessory.icon_state)
+ else
+ // This means it didn't have an icon state
+ possible_values[name] = icon('icons/mob/landmarks.dmi', "x")
+ return possible_values
+
+/// Takes an assoc list of names to /datum/sprite_accessory and returns a value
+/// fit for `/datum/preference/init_possible_values()`
+/// Different from `possible_values_for_sprite_accessory_list` in that it takes a list of layers
+/// such as BEHIND, FRONT, and ADJ.
+/// It also takes a "body part name", such as body_markings, moth_wings, etc
+/// They are expected to be in order from lowest to top.
+/proc/possible_values_for_sprite_accessory_list_for_body_part(
+ list/datum/sprite_accessory/sprite_accessories,
+ body_part,
+ list/layers,
+)
+ var/list/possible_values = list()
+
+ for (var/name in sprite_accessories)
+ var/datum/sprite_accessory/sprite_accessory = sprite_accessories[name]
+
+ var/icon/final_icon
+
+ for (var/layer in layers)
+ var/icon/icon = icon(sprite_accessory.icon, "m_[body_part]_[sprite_accessory.icon_state]_[layer]")
+
+ if (isnull(final_icon))
+ final_icon = icon
+ else
+ final_icon.Blend(icon, ICON_OVERLAY)
+
+ possible_values[name] = final_icon
+
+ return possible_values
+
+/// A numeric preference with a minimum and maximum value
+/datum/preference/numeric
+ /// The minimum value
+ var/minimum
+
+ /// The maximum value
+ var/maximum
+
+ /// The step of the number, such as 1 for integers or 0.5 for half-steps.
+ var/step = 1
+
+ abstract_type = /datum/preference/numeric
+
+/datum/preference/numeric/deserialize(input, datum/preferences/preferences)
+ if(istext(input)) // Sometimes TGUI will return a string instead of a number, so we take that into account.
+ input = text2num(input) // Worst case, it's null, it'll just use create_default_value()
+ return sanitize_float(input, minimum, maximum, step, create_default_value())
+
+/datum/preference/numeric/serialize(input)
+ return sanitize_float(input, minimum, maximum, step, create_default_value())
+
+/datum/preference/numeric/create_default_value()
+ return rand(minimum, maximum)
+
+/datum/preference/numeric/is_valid(value)
+ return isnum(value) && value >= round(minimum, step) && value <= round(maximum, step)
+
+/datum/preference/numeric/compile_constant_data()
+ return list(
+ "minimum" = minimum,
+ "maximum" = maximum,
+ "step" = step,
+ )
+
+/// A prefernece whose value is always TRUE or FALSE
+/datum/preference/toggle
+ abstract_type = /datum/preference/toggle
+
+ /// The default value of the toggle, if create_default_value is not specified
+ var/default_value = TRUE
+
+/datum/preference/toggle/create_default_value()
+ return default_value
+
+/datum/preference/toggle/deserialize(input, datum/preferences/preferences)
+ if(istext(input))
+ input = text2num(input)
+ return !!input
+
+/datum/preference/toggle/is_valid(value)
+ return value == TRUE || value == FALSE
+
+/// A simple string type preference.
+/datum/preference/string
+ abstract_type = /datum/preference/string
+
+ /// The default value of the string, if create_default_value is not specified
+ var/default_value = ""
+
+/datum/preference/string/create_default_value()
+ return default_value
+
+/datum/preference/string/deserialize(input, datum/preferences/preferences)
+ return sanitize_text(input, create_default_value())
+
+/datum/preference/string/is_valid(value)
+ return istext(value)
diff --git a/code/modules/client/preferences/preference_verbs.dm b/code/modules/client/preferences/preference_verbs.dm
new file mode 100644
index 0000000000000..f70187747edb7
--- /dev/null
+++ b/code/modules/client/preferences/preference_verbs.dm
@@ -0,0 +1,25 @@
+/client/verb/open_character_preferences()
+ set category = "Preferences"
+ set name = "Character Preferences"
+ set desc = "Open Character Preferences"
+
+ var/datum/preferences/preferences = usr?.client?.prefs
+ if (!preferences)
+ return
+
+ preferences.current_window = PREFERENCE_TAB_CHARACTER_PREFERENCES
+ preferences.update_static_data(usr)
+ preferences.ui_interact(usr)
+
+/client/verb/open_game_preferences()
+ set category = "Preferences"
+ set name = "Game Preferences"
+ set desc = "Open Game Preferences"
+
+ var/datum/preferences/preferences = usr?.client?.prefs
+ if (!preferences)
+ return
+
+ preferences.current_window = PREFERENCE_TAB_GAME_PREFERENCES
+ preferences.update_static_data(usr)
+ preferences.ui_interact(usr)
diff --git a/code/modules/client/preferences/preference_verbs_toggles.dm b/code/modules/client/preferences/preference_verbs_toggles.dm
new file mode 100644
index 0000000000000..d953bb9dd92e8
--- /dev/null
+++ b/code/modules/client/preferences/preference_verbs_toggles.dm
@@ -0,0 +1,43 @@
+/client/verb/toggletitlemusic()
+ set name = "Hear/Silence Lobby Music"
+ set category = "Preferences"
+ set desc = "Hear Music In Lobby"
+ var/hear = !prefs.read_player_preference(/datum/preference/toggle/sound_lobby)
+ prefs.update_preference(/datum/preference/toggle/sound_lobby, hear)
+ if(hear)
+ to_chat(usr, "You will now hear music in the game lobby.")
+ else
+ to_chat(usr, "You will no longer hear music in the game lobby.")
+ SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Lobby Music", "[hear ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
+
+/client/verb/Toggle_Soundscape()
+ set name = "Hear/Silence Ambience"
+ set category = "Preferences"
+ set desc = "Hear Ambient Sound Effects"
+ var/hear = !prefs.read_player_preference(/datum/preference/toggle/sound_ambience)
+ prefs.update_preference(/datum/preference/toggle/sound_ambience, hear)
+ if(hear)
+ to_chat(usr, "You will now hear ambient sounds.")
+ else
+ to_chat(usr, "You will no longer hear ambient sounds.")
+ SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ambience", "[hear ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
+
+/client/verb/toggle_ship_ambience()
+ set name = "Hear/Silence Ship Ambience"
+ set category = "Preferences"
+ set desc = "Hear Ship Ambience Roar"
+ var/hear = !prefs.read_player_preference(/datum/preference/toggle/sound_ship_ambience)
+ prefs.update_preference(/datum/preference/toggle/sound_ship_ambience, hear)
+ if(hear)
+ to_chat(usr, "You will now hear ship ambience.")
+ else
+ to_chat(usr, "You will no longer hear ship ambience.")
+ SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ship Ambience", "[hear ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, I bet you read this comment expecting to see the same thing :^)
+
+/client/verb/stop_client_sounds()
+ set name = "Stop Sounds"
+ set category = "Preferences"
+ set desc = "Stop Current Sounds"
+ SEND_SOUND(usr, sound(null))
+ tgui_panel?.stop_music()
+ SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Stop Self Sounds")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
diff --git a/code/modules/client/preferences/preferences.dm b/code/modules/client/preferences/preferences.dm
new file mode 100644
index 0000000000000..4fd89a32c62e6
--- /dev/null
+++ b/code/modules/client/preferences/preferences.dm
@@ -0,0 +1,396 @@
+GLOBAL_LIST_EMPTY(preferences_datums)
+
+/datum/preferences
+ var/client/parent
+
+ /// The current active slot, and the one that will be saved as active
+ var/default_slot = 1
+ /// The maximum number of slots we're allowed to contain
+ var/max_save_slots = 3
+ /// Cache for the current active character slot
+ var/datum/preferences_holder/preferences_character/character_data
+ /// Cache for player datumized preferences
+ var/datum/preferences_holder/preferences_player/player_data
+
+ /// Bitflags for communications that are muted
+ var/muted = NONE
+ /// Last IP that this client has connected from
+ var/last_ip
+ /// Last CID that this client has connected from
+ var/last_id
+
+ // pAI profile
+ var/pai_name = ""
+ var/pai_description = ""
+ var/pai_comment = ""
+
+ /// Cached changelog size, to detect new changelogs since last join
+ var/lastchangelog = ""
+
+ /// List of ROLE_X that the client wants to be eligible for (PER CHARACTER)
+ /// Use /client/proc/role_preference_enabled() please
+ var/list/role_preferences = list()
+
+ /// List of ROLE_X that the client wants to be eligible for (GLOBALLY)
+ /// Use /client/proc/role_preference_enabled() please
+ var/list/role_preferences_global = list()
+
+ /// Custom keybindings. Map of keybind names to keyboard inputs.
+ /// For example, by default would have "swap_hands" -> "X"
+ var/list/key_bindings = list()
+
+ /// Cached list of keybindings, mapping keys to actions.
+ /// For example, by default would have "X" -> list("swap_hands")
+ var/list/key_bindings_by_key = list()
+
+ var/db_flags
+
+ //character preferences
+ var/slot_randomized //keeps track of round-to-round randomization of the character slot, prevents overwriting
+
+ var/list/randomise = list()
+
+ //Quirk list
+ var/list/all_quirks = list()
+
+ //Job preferences 2.0 - indexed by job title , no key or value implies never
+ var/list/job_preferences = list()
+
+ /// The current window, PREFERENCE_TAB_* in [`code/__DEFINES/preferences.dm`]
+ var/current_window = PREFERENCE_TAB_CHARACTER_PREFERENCES
+
+ /// If the user is a BYOND Member
+ var/unlock_content = 0
+
+ var/list/ignoring = list()
+
+ var/list/purchased_gear = list()
+ var/list/equipped_gear = list()
+
+ var/list/exp = list()
+ var/job_exempt = 0
+
+ var/action_buttons_screen_locs = list()
+
+ ///What outfit typepaths we've favorited in the SelectEquipment menu
+ var/list/favorite_outfits = list()
+
+ /// A preview of the current character
+ var/atom/movable/screen/map_view/character_preview_view/character_preview_view
+
+ /// A list of instantiated middleware
+ var/list/datum/preference_middleware/middleware = list()
+
+ /// A list of keys that have been updated since the last save.
+ var/list/recently_updated_keys = list()
+
+ /// List of slot index -> character names
+ var/list/character_profiles_cached
+
+ /// If the last save was a success or not. True for success, false for fail.
+ var/fail_state = TRUE
+
+/datum/preferences/Destroy(force, ...)
+ QDEL_NULL(character_preview_view)
+ QDEL_LIST(middleware)
+ QDEL_NULL(character_data)
+ QDEL_NULL(player_data)
+ return ..()
+
+/datum/preferences/New(client/parent)
+ src.parent = parent
+
+ for (var/middleware_type in subtypesof(/datum/preference_middleware))
+ middleware += new middleware_type(src)
+
+ if(istype(parent))
+ if(!IS_GUEST_KEY(parent.key))
+ unlock_content = !!parent.IsByondMember()
+ if(unlock_content)
+ max_save_slots = 8
+ else
+ CRASH("attempted to create a preferences datum without a client!")
+
+ // give them default keybinds and update their movement keys
+ set_default_key_bindings(save = FALSE) // no point in saving these since everyone gets them. They'll be saved if needed.
+ randomise = get_default_randomization()
+
+ var/loaded_preferences_successfully = load_preferences()
+ if(loaded_preferences_successfully)
+ if("6030fe461e610e2be3a2c3e75c06067e" in purchased_gear) //MD5 hash of, "extra character slot"
+ max_save_slots += 1
+ if(load_character()) // This returns true if there is a database and character in the active slot.
+ // Get the profile data
+ fetch_character_profiles()
+ create_character_preview_view()
+ return
+ // Begin no database / new player logic. This ONLY fires if there is an SQL error or no database / the player and character is new.
+
+ if(!loaded_preferences_successfully) // create a new character object
+ character_data = new(src, default_slot)
+ // Get the profile data
+ fetch_character_profiles()
+ var/new_species_path = GLOB.species_list[get_fallback_species_id() || "human"]
+ character_data.write_preference(src, GLOB.preference_entries[/datum/preference/choiced/species], new_species_path)
+ // We couldn't load character data so just randomize the character appearance
+ randomize_appearance_prefs()
+ if(parent)
+ apply_all_client_preferences() // apply now since normally this is done in load_preferences(). Defaults were set in preferences_player
+
+ // The character name is fresh, update the character list.
+ update_current_character_profile()
+ create_character_preview_view()
+
+ // If this was a NEW CKEY ENTRY, and not a guest key (handled in save_preferences()), save it.
+ // Guest keys are ignored by mark_undatumized_dirty
+ if(!loaded_preferences_successfully)
+ // This will essentially force a write, while also using the queueing system.
+ // For new ckeys, it is almost guaranteed we already hit the queue, since write_preference (used for when a datumized entry is null)
+ // Will also queue the CKEY. But this will also ensure that undatumized prefs get written.
+ mark_undatumized_dirty_player()
+ mark_undatumized_dirty_character()
+
+/datum/preferences/ui_interact(mob/user, datum/tgui/ui)
+ // IMPORTANT: If someone opens the prefs menu before jobs load, then the jobs menu will be empty for everyone.
+ // Do NOT call ui_assets until the jobs are loaded.
+ if(!length(SSjob.occupations))
+ return
+
+ // If you leave and come back, re-register the character preview. This also runs the first time it's opened
+ if (!isnull(character_preview_view) && istype(user.client) && !(character_preview_view in user.client.screen))
+ character_preview_view.register_to_client(user.client)
+
+ // Just force an update for funsies
+ character_preview_view.update_body()
+
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "PreferencesMenu")
+ ui.set_autoupdate(FALSE)
+ ui.open()
+
+/datum/preferences/ui_state(mob/user)
+ return GLOB.always_state
+
+// Without this, a hacker would be able to edit other people's preferences if
+// they had the ref to Topic to.
+/datum/preferences/ui_status(mob/user, datum/ui_state/state)
+ return user.client == parent ? UI_INTERACTIVE : UI_CLOSE
+
+/datum/preferences/ui_data(mob/user)
+ var/list/data = list()
+
+ data["character_profiles"] = character_profiles_cached
+
+ data["character_preferences"] = compile_character_preferences(user)
+
+ data["active_slot"] = default_slot
+ data["max_slot"] = max_save_slots
+ data["save_in_progress"] = !isnull(SSpreferences.datums[parent.ckey])
+ data["is_guest"] = !!IS_GUEST_KEY(parent.key)
+ data["is_db"] = !!SSdbcore.IsConnected()
+ data["save_sucess"] = !!fail_state
+
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ data += preference_middleware.get_ui_data(user)
+
+ return data
+
+/datum/preferences/ui_static_data(mob/user)
+ var/list/data = list()
+
+ data["character_preview_view"] = character_preview_view.assigned_map
+ data["overflow_role"] = SSjob.GetJob(SSjob.overflow_role).title
+ data["window"] = current_window
+
+ data["content_unlocked"] = unlock_content
+
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ data += preference_middleware.get_ui_static_data(user)
+
+ return data
+
+/datum/preferences/ui_assets(mob/user)
+ var/list/assets = list(
+ get_asset_datum(/datum/asset/spritesheet/preferences),
+ get_asset_datum(/datum/asset/spritesheet/preferences_large),
+ get_asset_datum(/datum/asset/spritesheet/preferences_huge),
+ get_asset_datum(/datum/asset/spritesheet/preferences_loadout),
+ get_asset_datum(/datum/asset/json/preferences),
+ )
+
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ assets += preference_middleware.get_ui_assets()
+
+ return assets
+
+/datum/preferences/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if (.)
+ return
+
+ switch (action)
+ if ("change_slot")
+ var/new_slot = params["slot"]
+ if(new_slot == default_slot) // No need to change to the current character.
+ return
+ // Save previous character (immediately, delaying this could mean data is lost)
+ save_character()
+
+ // SAFETY: `load_character` performs sanitization the slot number
+ if (!load_character(new_slot))
+ // there is no character in the slot. Make a new one. Save it.
+ update_current_character_profile()
+ randomize_appearance_prefs()
+ // Queue an undatumized save, just in case (it's likely already queued, but we should write undatumized data as well)
+ mark_undatumized_dirty_character()
+
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ preference_middleware.on_new_character(usr)
+
+ character_preview_view.update_body()
+
+ return TRUE
+ if ("rotate")
+ var/direction = !!params["direction"]
+ if(isatom(character_preview_view.body))
+ character_preview_view.body.dir = turn(character_preview_view.body.dir, (direction ? 1 : -1) * 90)
+
+ return TRUE
+ if ("set_preference")
+ var/requested_preference_key = params["preference"]
+ var/value = params["value"]
+
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ if (preference_middleware.pre_set_preference(usr, requested_preference_key, value))
+ return TRUE
+
+ var/datum/preference/requested_preference = GLOB.preference_entries_by_key[requested_preference_key]
+ if (isnull(requested_preference))
+ return FALSE
+
+ // SAFETY: `update_preference` performs validation checks
+ if (!update_preference(requested_preference, value))
+ return FALSE
+
+ if (istype(requested_preference, /datum/preference/name/real_name))
+ update_current_character_profile()
+
+ return TRUE
+ if ("set_color_preference")
+ var/requested_preference_key = params["preference"]
+
+ var/datum/preference/requested_preference = GLOB.preference_entries_by_key[requested_preference_key]
+ if (isnull(requested_preference))
+ return FALSE
+
+ if (!istype(requested_preference, /datum/preference/color) \
+ && !istype(requested_preference, /datum/preference/color_legacy) \
+ )
+ return FALSE
+
+ var/default_value = read_preference(requested_preference.type)
+ if (istype(requested_preference, /datum/preference/color_legacy))
+ default_value = expand_three_digit_color(default_value)
+
+ // Yielding
+ var/new_color = tgui_color_picker(
+ usr,
+ "Select new color",
+ "Preference Color",
+ default_value || COLOR_WHITE,
+ )
+
+ if (!new_color)
+ return FALSE
+
+ if (!update_preference(requested_preference, new_color))
+ return FALSE
+
+ return TRUE
+ if("open_game_preferences")
+ current_window = PREFERENCE_TAB_GAME_PREFERENCES
+ update_static_data(usr)
+ ui_interact(usr)
+ return TRUE
+ if("open_character_preferences")
+ current_window = PREFERENCE_TAB_CHARACTER_PREFERENCES
+ update_static_data(usr)
+ ui_interact(usr)
+ return TRUE
+
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ var/delegation = preference_middleware.action_delegations[action]
+ if (!isnull(delegation))
+ return call(preference_middleware, delegation)(params, usr)
+
+ return FALSE
+
+/datum/preferences/ui_close(mob/user)
+ // Save immediately. This should also handle if the player disconnects before their mob/ckey/client is null.
+ save_character()
+ save_preferences()
+ character_preview_view.unregister_from_client(user.client)
+
+/datum/preferences/proc/compile_character_preferences(mob/user)
+ var/list/preferences = list()
+
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (!preference.is_accessible(src))
+ continue
+
+ LAZYINITLIST(preferences[preference.category])
+
+ var/value = read_preference(preference.type)
+ var/data = preference.compile_ui_data(user, value)
+
+ preferences[preference.category][preference.db_key] = data
+
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ var/list/append_character_preferences = preference_middleware.get_character_preferences(user)
+ if (isnull(append_character_preferences))
+ continue
+
+ for (var/category in append_character_preferences)
+ if (category in preferences)
+ preferences[category] += append_character_preferences[category]
+ else
+ preferences[category] = append_character_preferences[category]
+
+ return preferences
+
+/// Applies all PREFERENCE_PLAYER preferences immediately
+/datum/preferences/proc/apply_all_client_preferences()
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (preference.preference_type != PREFERENCE_PLAYER)
+ continue
+ preference.apply_to_client(parent, read_player_preference(preference.type))
+
+/// Updates cached character list with new real_name
+/datum/preferences/proc/update_current_character_profile()
+ if(!islist(character_profiles_cached))
+ return
+ character_profiles_cached[default_slot] = read_character_preference(/datum/preference/name/real_name)
+
+/// Immediately refetch the character list
+/datum/preferences/proc/fetch_character_profiles()
+ character_data.get_all_character_names(src)
+
+/// Applies the given preferences to a human mob.
+/datum/preferences/proc/apply_prefs_to(mob/living/carbon/human/character, icon_updates = TRUE)
+ character.dna.features = list()
+
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (preference.preference_type != PREFERENCE_CHARACTER)
+ continue
+
+ preference.apply_to_human(character, read_character_preference(preference.type))
+
+ character.dna.real_name = character.real_name
+
+ if(icon_updates)
+ character.icon_render_keys = list() // turns out if you don't set this to null update_body_parts does nothing, since it assumes the operation was cached
+ character.update_body()
+ character.update_hair()
+ character.update_body_parts(TRUE) // Must pass true here or limbs won't catch changes like body_model
+ character.dna.update_body_size()
diff --git a/code/modules/client/preferences/serialization/preferences_character.dm b/code/modules/client/preferences/serialization/preferences_character.dm
new file mode 100644
index 0000000000000..30b300600554e
--- /dev/null
+++ b/code/modules/client/preferences/serialization/preferences_character.dm
@@ -0,0 +1,183 @@
+/// A cache for character preferences data
+/datum/preferences_holder/preferences_character
+ /// INT: Slot number. Used for internal tracking. The slot number also correspnds to the number of slots in the characters list
+ var/slot_number = 0
+ /// List of column names to be queried
+ var/static/list/column_names
+
+/// Block varedits to column_names
+/datum/preferences_holder/preferences_character/vv_edit_var(var_name, var_value)
+ var/static/list/banned_edits = list(NAMEOF_STATIC(src, column_names))
+ return !(var_name in banned_edits) && ..()
+
+/// Initialize the data cache with default values
+/datum/preferences_holder/preferences_character/New(datum/preferences/prefs, slot)
+ slot_number = slot
+ if(!length(column_names))
+ column_names = get_column_names()
+ ..(prefs)
+
+/datum/preferences_holder/preferences_character/proc/load_from_database(datum/preferences/prefs)
+ if(IS_GUEST_KEY(prefs.parent.key) || !query_data(prefs)) // Query direct, otherwise create informed defaults
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (preference.preference_type != pref_type)
+ continue
+ preference_data[preference.db_key] = preference.deserialize(preference.create_informed_default_value(prefs), prefs)
+ return FALSE
+ return TRUE
+
+/datum/preferences_holder/preferences_character/proc/query_data(datum/preferences/prefs)
+ if(!SSdbcore.IsConnected())
+ return FALSE
+ var/list/values
+ var/datum/DBQuery/Q = SSdbcore.NewQuery(
+ "SELECT [db_column_list(column_names)] FROM [format_table_name("characters")] WHERE ckey=:ckey AND slot=:slot",
+ list("ckey" = prefs.parent.ckey, "slot" = slot_number)
+ )
+ if(!Q.warn_execute())
+ qdel(Q)
+ return FALSE
+ if(Q.NextRow())
+ values = Q.item
+ if(!length(values)) // There is no character
+ qdel(Q)
+ return FALSE
+ else
+ qdel(Q)
+ return FALSE
+ qdel(Q)
+ if(length(values) != length(column_names))
+ CRASH("Error querying character data: the returned value length is not equal to the number of columns requested.")
+ for(var/index in 1 to length(values))
+ var/db_key = column_names[index]
+ var/datum/preference/preference = GLOB.preference_entries_by_key[db_key]
+ if(!istype(preference))
+ CRASH("Could not find preference with db_key [db_key] when querying database.")
+ var/value = values[index]
+ preference_data[db_key] = isnull(value) ? null : preference.deserialize(value, prefs)
+ return TRUE
+
+/datum/preferences_holder/preferences_character/proc/write_to_database(datum/preferences/prefs)
+ . = write_data(prefs)
+ dirty_prefs.Cut() // clear all dirty preferences
+
+/datum/preferences_holder/preferences_character/proc/write_data(datum/preferences/prefs)
+ if(!SSdbcore.IsConnected() || IS_GUEST_KEY(prefs.parent.key))
+ return FALSE
+ var/list/column_names_short = list()
+ var/list/new_data = list()
+ for(var/db_key in dirty_prefs)
+ if(!(db_key in preference_data))
+ CRASH("Invalid db_key found in dirty preferences list: [db_key].")
+ var/datum/preference/preference = GLOB.preference_entries_by_key[db_key]
+ if(!istype(preference))
+ CRASH("Could not find preference with db_key [db_key] when writing to database.")
+ new_data[db_key] = preference.serialize(preference_data[db_key])
+ var/column_name = clean_column_name(preference)
+ if(length(column_name))
+ column_names_short += column_name
+ if(!length(column_names_short)) // nothing to update
+ return TRUE
+ new_data["ckey"] = prefs.parent.ckey
+ new_data["slot"] = slot_number
+ var/datum/DBQuery/Q = SSdbcore.NewQuery(
+ "INSERT INTO [format_table_name("characters")] (ckey, slot, [db_column_list(column_names_short)]) VALUES (:ckey, :slot, [db_column_list(column_names_short, TRUE)]) ON DUPLICATE KEY UPDATE [db_column_values(column_names_short)]", new_data
+ )
+ var/success = Q.warn_execute()
+ if(!success)
+ to_chat(prefs.parent, "Failed to save your character. Please inform the server operator or a maintainer of this error.")
+ qdel(Q)
+ prefs.fail_state = success
+ return success
+
+/datum/preferences_holder/preferences_character/proc/get_column_names()
+ var/list/result = list()
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (preference.preference_type != PREFERENCE_CHARACTER)
+ continue
+ // IMPORTANT: use of initial evades varedits. Filter to only alphanumeric and underscores
+ var/column_name = clean_column_name(preference)
+ if(length(column_name))
+ result += column_name
+ if(!length(result))
+ CRASH("Something is very wrong, /datum/prefence_character/proc/get_column_names() returned a zero length list.")
+ return result
+
+/datum/preferences_holder/preferences_character/proc/clean_column_name(datum/preference/preference)
+ var/column_name = reject_bad_text(initial(preference.db_key), max_length = 64, ascii_only = TRUE, alphanumeric_only = TRUE, underscore_allowed = TRUE)
+ if(!length(column_name) || findtext(column_name, " ") || column_name != preference.db_key)
+ CRASH("Invalid or possibly modified column name: '[column_name]' for db_key '[preference.db_key]'! Something bad is going on.")
+ return column_name
+
+/// Minimized copy of english_list because I don't want someone breaking this very important function later on
+/proc/db_column_list(list/input, colon = FALSE)
+ var/total = length(input)
+ switch(total)
+ if (0)
+ return ""
+ if (1)
+ return "[colon ? ":" : ""][input[1]]"
+ if (2)
+ return "[colon ? ":" : ""][input[1]], [colon ? ":" : ""][input[2]]"
+ else
+ var/output = ""
+ var/index = 1
+ while (index < total)
+ output += "[colon ? ":" : ""][input[index]], "
+ index++
+
+ return "[output][colon ? ":" : ""][input[index]]"
+
+/proc/db_column_values(list/input)
+ var/total = length(input)
+ switch(total)
+ if (0)
+ return ""
+ if (1)
+ return "[input[1]]=VALUES([input[1]])"
+ if (2)
+ return "[input[1]]=VALUES([input[1]]), [input[2]]=VALUES([input[2]])"
+ else
+ var/output = ""
+ var/index = 1
+ while (index < total)
+ output += "[input[index]]=VALUES([input[index]]),"
+ index++
+
+ return "[output][input[index]]=VALUES([input[index]])"
+
+
+/datum/preferences_holder/preferences_character/proc/get_all_character_names(datum/preferences/prefs)
+ if(!SSdbcore.IsConnected() || IS_GUEST_KEY(prefs.parent.key))
+ var/list/data = list()
+ for(var/index in 1 to TRUE_MAX_SAVE_SLOTS)
+ data += null
+ // Only the current slot is valid
+ data[prefs.default_slot] = read_preference(prefs, GLOB.preference_entries[/datum/preference/name/real_name])
+ prefs.character_profiles_cached = data
+ return
+ var/datum/DBQuery/Q = SSdbcore.NewQuery(
+ "SELECT slot,real_name FROM [format_table_name("characters")] WHERE ckey=:ckey",
+ list("ckey" = prefs.parent.ckey)
+ )
+ if(!Q.warn_execute())
+ qdel(Q)
+ CRASH("An SQL error occurred while retrieving character profile data.")
+ var/list/data = list()
+ for(var/index in 1 to TRUE_MAX_SAVE_SLOTS)
+ data += null
+ while(Q.NextRow())
+ var/list/values = Q.item
+ if(length(values) != 2)
+ CRASH("Error querying character profile data: the returned value length is greater than the number of columns requested.")
+ if(!isnum(values[1]))
+ CRASH("Error querying character profile data: slot number was not a number")
+ if(!istext(values[2]))
+ CRASH("Error querying character profile data: character name was not a string")
+ if(values[1] > TRUE_MAX_SAVE_SLOTS)
+ CRASH("Slot number in database is greater than the maximum allowed slots! Please purge this character entry or increase the slot number.")
+ data[values[1]] = values[2] // data[1] = "John Smith"
+ qdel(Q)
+ prefs.character_profiles_cached = data
diff --git a/code/modules/client/preferences/serialization/preferences_database.dm b/code/modules/client/preferences/serialization/preferences_database.dm
new file mode 100644
index 0000000000000..64ac01ab1a030
--- /dev/null
+++ b/code/modules/client/preferences/serialization/preferences_database.dm
@@ -0,0 +1,353 @@
+/datum/preferences/var/dirty_undatumized_preferences_player = FALSE
+/datum/preferences/var/dirty_undatumized_preferences_character = FALSE
+
+/// Marks undatumized preferences as dirty, so it will be serialized on the next preference write.
+/// Queues a preference write.
+/// Use this for player preferences only.
+/datum/preferences/proc/mark_undatumized_dirty_player()
+ if(IS_GUEST_KEY(parent.key)) // NO saving guests to the DB!
+ return FALSE
+ dirty_undatumized_preferences_player = TRUE
+ SSpreferences.queue_write(src)
+
+/// Marks undatumized preferences as dirty, so it will be serialized on the next preference write.
+/// Queues a preference write.
+/// Use this for character preferences only.
+/datum/preferences/proc/mark_undatumized_dirty_character()
+ if(IS_GUEST_KEY(parent.key)) // NO saving guests to the DB!
+ return FALSE
+ dirty_undatumized_preferences_character = TRUE
+ SSpreferences.queue_write(src)
+
+/// If any character preference is dirty.
+/datum/preferences/proc/ready_to_save_character()
+ return dirty_undatumized_preferences_character || length(character_data.dirty_prefs)
+
+/// If any player preference is dirty.
+/datum/preferences/proc/ready_to_save_player()
+ return dirty_undatumized_preferences_player || length(player_data.dirty_prefs)
+
+// Defines for list sanity
+#define READPREF_STR(target, tag) if(prefmap[tag]) target = prefmap[tag]
+#define READPREF_INT(target, tag) if(prefmap[tag]) target = text2num(prefmap[tag])
+
+// Did you know byond has try/catch? We use it here so malformed JSON doesnt break the entire loading system
+#define READPREF_JSONDEC(target, tag) \
+ try {\
+ if(prefmap[tag]) {\
+ target = json_decode(prefmap[tag]);\
+ };\
+ } catch {\
+ pass();\
+ } // we dont need error handling where were going
+
+/datum/preferences/proc/load_preferences()
+ // Get the datumized stuff first
+ player_data = new(src)
+ if(!player_data.load_from_database(src)) // checks db connection
+ return FALSE
+
+ var/datum/DBQuery/read_player_data = SSdbcore.NewQuery(
+ "SELECT CAST(preference_tag AS CHAR) AS ptag, preference_value FROM [format_table_name("preferences")] WHERE ckey=:ckey",
+ list("ckey" = parent.ckey)
+ )
+
+ // K:pref tag | V:pref value
+ // DO NOT RENAME THIS. SERIOUSLY. DO NOT RENAME THIS LIST. IT'S USED IN THE READPREF DEFINES.
+ var/list/prefmap = list()
+
+ if(!read_player_data.Execute())
+ qdel(read_player_data)
+ return FALSE
+ else
+ while(read_player_data.NextRow())
+ prefmap[read_player_data.item[1]] = read_player_data.item[2]
+ qdel(read_player_data)
+
+ READPREF_INT(default_slot, PREFERENCE_TAG_DEFAULT_SLOT)
+ READPREF_STR(lastchangelog, PREFERENCE_TAG_LAST_CL)
+
+ READPREF_STR(pai_name, PREFERENCE_TAG_PAI_NAME)
+ READPREF_STR(pai_description, PREFERENCE_TAG_PAI_DESCRIPTION)
+ READPREF_STR(pai_comment, PREFERENCE_TAG_PAI_COMMENT)
+
+ READPREF_JSONDEC(ignoring, PREFERENCE_TAG_IGNORING)
+ READPREF_JSONDEC(purchased_gear, PREFERENCE_TAG_PURCHASED_GEAR)
+ READPREF_JSONDEC(role_preferences_global, PREFERENCE_TAG_ROLE_PREFERENCES_GLOBAL)
+
+ // Custom hotkeys
+ READPREF_JSONDEC(key_bindings, PREFERENCE_TAG_KEYBINDS)
+
+ //Sanitize
+ lastchangelog = sanitize_text(lastchangelog, initial(lastchangelog))
+ default_slot = sanitize_integer(default_slot, 1, TRUE_MAX_SAVE_SLOTS, initial(default_slot))
+ ignoring = SANITIZE_LIST(ignoring)
+ purchased_gear = SANITIZE_LIST(purchased_gear)
+ role_preferences_global = SANITIZE_LIST(role_preferences_global)
+
+ pai_name = sanitize_text(pai_name, initial(pai_name))
+ pai_description = sanitize_text(pai_description, initial(pai_description))
+ pai_comment = sanitize_text(pai_comment, initial(pai_comment))
+
+ key_bindings = sanitize_islist(key_bindings, deep_copy_list(GLOB.keybindings_by_name_to_key))
+ key_bindings_by_key = get_key_bindings_by_key(key_bindings)
+
+ // Remove any invalid role preference entries
+ for(var/preference in role_preferences_global)
+ var/path = text2path(preference)
+ var/datum/role_preference/entry = GLOB.role_preference_entries[path]
+ if(istype(entry))
+ continue
+ role_preferences_global -= preference
+
+ if (!length(key_bindings))
+ set_default_key_bindings(save = TRUE)
+ else
+ var/any_changed = FALSE
+ for(var/key_name in GLOB.keybindings_by_name)
+ var/datum/keybinding/keybind = GLOB.keybindings_by_name[key_name]
+ if(key_name in key_bindings) // The bind exists in our keybind data. Good! Skip it.
+ continue
+ // Assign the default keybindings to the key, since there are none set.
+ set_keybind(key_name, keybind.keys.Copy())
+ any_changed = TRUE
+ if(any_changed)
+ mark_undatumized_dirty_player() // Write the new keybinds to the database.
+ apply_all_client_preferences()
+ return TRUE
+
+#undef READPREF_STR
+#undef READPREF_INT
+#undef READPREF_JSONDEC
+
+#define PREP_WRITEPREF_STR(value, tag) write_queries += SSdbcore.NewQuery("INSERT INTO [format_table_name("preferences")] (ckey, preference_tag, preference_value) VALUES (:ckey, :ptag, :pvalue) ON DUPLICATE KEY UPDATE preference_value=:pvalue2", list("ckey" = parent.ckey, "ptag" = tag, "pvalue" = value, "pvalue2" = value))
+#define PREP_WRITEPREF_JSONENC(value, tag) PREP_WRITEPREF_STR(json_encode(value), tag)
+
+/datum/preferences/proc/save_preferences()
+ if(!SSdbcore.IsConnected())
+ return FALSE
+ if(!istype(parent))
+ return FALSE
+ if(IS_GUEST_KEY(parent.key)) // NO saving guests to the DB!
+ return FALSE
+ if(!player_data?.write_to_database(src))
+ return FALSE
+ if(!dirty_undatumized_preferences_player) // Nothing to write. Call it a success.
+ return TRUE
+ dirty_undatumized_preferences_player = FALSE // we edit this immediately, since the DB query sleeps, the var could be modified during the sleep.
+ var/list/datum/DBQuery/write_queries = list() // do not rename this you muppet
+
+ PREP_WRITEPREF_STR(default_slot, PREFERENCE_TAG_DEFAULT_SLOT)
+ PREP_WRITEPREF_STR(lastchangelog, PREFERENCE_TAG_LAST_CL)
+
+ PREP_WRITEPREF_STR(pai_name, PREFERENCE_TAG_PAI_NAME)
+ PREP_WRITEPREF_STR(pai_description, PREFERENCE_TAG_PAI_DESCRIPTION)
+ PREP_WRITEPREF_STR(pai_comment, PREFERENCE_TAG_PAI_COMMENT)
+
+ PREP_WRITEPREF_JSONENC(ignoring, PREFERENCE_TAG_IGNORING)
+ PREP_WRITEPREF_JSONENC(key_bindings, PREFERENCE_TAG_KEYBINDS)
+ PREP_WRITEPREF_JSONENC(purchased_gear, PREFERENCE_TAG_PURCHASED_GEAR)
+ PREP_WRITEPREF_JSONENC(role_preferences_global, PREFERENCE_TAG_ROLE_PREFERENCES_GLOBAL)
+
+ // QuerySelect can execute many queries at once. That name is dumb but w/e
+ SSdbcore.QuerySelect(write_queries, TRUE, TRUE)
+ return TRUE
+
+#undef PREP_WRITEPREF_STR
+#undef PREP_WRITEPREF_JSONENC
+
+#define JSONREAD_PREF(target, tag) \
+ try {\
+ var/idx = column_names?.Find(tag);\
+ if(idx > 0) {\
+ target = json_decode(values[idx]);\
+ } else {\
+ log_runtime("Missing preference tag '[tag]' in columns: [english_list(column_names)]");\
+ };\
+ } catch {\
+ target = null;\
+ pass();\
+ } // we dont need error handling where were going
+
+/datum/preferences/proc/load_character(slot)
+ if(!slot)
+ slot = default_slot
+ slot = sanitize_integer(slot, 1, max_save_slots, initial(default_slot))
+ if(slot != default_slot)
+ default_slot = slot
+ mark_undatumized_dirty_player()
+
+ character_data = new(src, slot)
+ if(!character_data.load_from_database(src)) // checks db connection
+ return FALSE
+
+ // Do NOT statically cache this or I will kill you. You are asking an evil vareditor to break the DB in a BAD way
+ // also DO NOT rename this
+ var/list/column_names = list(
+ "slot", // this is a literal column name
+ CHARACTER_PREFERENCE_RANDOMISE,
+ CHARACTER_PREFERENCE_JOB_PREFERENCES,
+ CHARACTER_PREFERENCE_ALL_QUIRKS,
+ CHARACTER_PREFERENCE_EQUIPPED_GEAR,
+ CHARACTER_PREFERENCE_ROLE_PREFERENCES,
+ )
+
+ var/datum/DBQuery/Q = SSdbcore.NewQuery(
+ "SELECT [db_column_list(column_names)] FROM [format_table_name("characters")] WHERE ckey=:ckey AND slot=:slot",
+ list("ckey" = parent.ckey, "slot" = slot)
+ )
+
+ // DON'T RENAME THIS.
+ var/list/values
+ if(!Q.warn_execute())
+ qdel(Q)
+ return FALSE
+ if(Q.NextRow())
+ values = Q.item
+ if(!length(values)) // There is no character
+ qdel(Q)
+ return FALSE
+ else
+ qdel(Q)
+ return FALSE
+ qdel(Q)
+ if(length(values) != length(column_names))
+ CRASH("Error querying character data: the returned value length is not equal to the number of columns requested.")
+
+ // Decode
+ JSONREAD_PREF(randomise, CHARACTER_PREFERENCE_RANDOMISE)
+ JSONREAD_PREF(job_preferences, CHARACTER_PREFERENCE_JOB_PREFERENCES)
+ JSONREAD_PREF(all_quirks, CHARACTER_PREFERENCE_ALL_QUIRKS)
+ JSONREAD_PREF(equipped_gear, CHARACTER_PREFERENCE_EQUIPPED_GEAR)
+ JSONREAD_PREF(role_preferences, CHARACTER_PREFERENCE_ROLE_PREFERENCES)
+
+ //Sanitize
+ randomise = SANITIZE_LIST(randomise)
+ job_preferences = SANITIZE_LIST(job_preferences)
+ all_quirks = SANITIZE_LIST(all_quirks)
+ equipped_gear = SANITIZE_LIST(equipped_gear)
+ role_preferences = SANITIZE_LIST(role_preferences)
+
+ // Validate job prefs
+ for(var/j in job_preferences)
+ if(job_preferences[j] != JP_LOW && job_preferences[j] != JP_MEDIUM && job_preferences[j] != JP_HIGH)
+ job_preferences -= j
+ mark_undatumized_dirty_character()
+
+ // Validate role prefs
+ for(var/preference in role_preferences)
+ var/path = text2path(preference)
+ var/datum/role_preference/entry = GLOB.role_preference_entries[path]
+ if(istype(entry) && entry.per_character)
+ continue
+ role_preferences -= preference
+ mark_undatumized_dirty_character()
+
+ // Validate equipped gear
+ for(var/gear_id in equipped_gear)
+ var/datum/gear/gear = GLOB.gear_datums[gear_id]
+ if(!length(GLOB.gear_datums)) // error safety, don't wanna clear everyone out
+ continue
+ if(!istype(gear))
+ equipped_gear -= gear_id
+ mark_undatumized_dirty_character()
+ continue
+ // Somehow have a gear equipped that you don't own...
+ if(islist(purchased_gear) && !(gear_id in purchased_gear))
+ equipped_gear -= gear_id
+ mark_undatumized_dirty_character()
+
+ return TRUE
+
+#undef JSONREAD_PREF
+
+#define WRITEPREF_STR(value, tag) new_data[tag] = value;column_names += tag
+#define WRITEPREF_JSONENC(value, tag) WRITEPREF_STR(json_encode(value), tag)
+
+/datum/preferences/proc/save_character()
+ if(!SSdbcore.IsConnected())
+ return FALSE
+ if(!istype(parent))
+ return FALSE
+ if(IS_GUEST_KEY(parent.key)) // NO saving guests to the DB!
+ return FALSE
+ if(!character_data?.write_to_database(src))
+ return FALSE
+ if(!dirty_undatumized_preferences_character) // Nothing to write. Call it a success.
+ return TRUE
+ dirty_undatumized_preferences_character = FALSE // we edit this immediately, since the DB query sleeps, the var could be modified during the sleep.
+
+ // DO NOT RENAME THESE LISTS! THANKS!! <3
+ var/list/column_names = list()
+ var/list/new_data = list()
+
+ WRITEPREF_JSONENC(randomise, CHARACTER_PREFERENCE_RANDOMISE)
+ WRITEPREF_JSONENC(job_preferences, CHARACTER_PREFERENCE_JOB_PREFERENCES)
+ WRITEPREF_JSONENC(all_quirks, CHARACTER_PREFERENCE_ALL_QUIRKS)
+ WRITEPREF_JSONENC(equipped_gear, CHARACTER_PREFERENCE_EQUIPPED_GEAR)
+ WRITEPREF_JSONENC(role_preferences, CHARACTER_PREFERENCE_ROLE_PREFERENCES)
+
+ new_data["ckey"] = parent.ckey
+ new_data["slot"] = character_data.slot_number
+ var/datum/DBQuery/Q = SSdbcore.NewQuery(
+ "INSERT INTO [format_table_name("characters")] (ckey, slot, [db_column_list(column_names)]) VALUES (:ckey, :slot, [db_column_list(column_names, TRUE)]) ON DUPLICATE KEY UPDATE [db_column_values(column_names)]", new_data
+ )
+ var/success = Q.warn_execute()
+ if(!success)
+ to_chat(parent, "Failed to save your character. Please inform the server operator or a maintainer of this error.")
+ qdel(Q)
+ fail_state = success
+ return success
+
+#undef WRITEPREF_STR
+#undef WRITEPREF_JSONENC
+
+/datum/preferences_holder
+ /// A map of db_key -> value. Data type varies.
+ var/list/preference_data
+ /// A list of preference db_keys that require writing
+ var/list/dirty_prefs
+ /// Preference type to parse
+ var/pref_type
+
+/datum/preferences_holder/New(datum/preferences/prefs)
+ preference_data = list()
+ dirty_prefs = list()
+ // Read everything into cache
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (preference.preference_type != pref_type || preference.informed)
+ continue
+
+ // we can't use informed values here. The name will get populated manually
+ preference_data[preference.db_key] = preference.deserialize(preference.create_default_value(), prefs)
+
+/datum/preferences_holder/proc/read_preference(datum/preferences/preferences, datum/preference/preference)
+ SHOULD_NOT_SLEEP(TRUE)
+ var/value = read_raw(preferences, preference)
+ if (isnull(value))
+ value = preference.create_informed_default_value(preferences)
+ if (write_preference(preferences, preference, value))
+ return value
+ else
+ CRASH("Couldn't write the default value for [preference.type] (received [value])")
+ return value
+
+/datum/preferences_holder/proc/read_raw(datum/preferences/preferences, datum/preference/preference)
+ // Data is already deserialized by the time it's in the cache. Don't deserialize it again.
+ var/value = preference_data[preference.db_key]
+ if (isnull(value))
+ return null
+ else
+ return value
+
+/datum/preferences_holder/proc/write_preference(datum/preferences/preferences, datum/preference/preference, value)
+ var/new_value = preference.deserialize(value, preferences)
+ if (!preference.is_valid(new_value))
+ return FALSE
+ preference_data[preference.db_key] = new_value
+ if(IS_GUEST_KEY(preferences.parent.key)) // NO saving guests to the DB!
+ return TRUE
+ dirty_prefs |= preference.db_key
+ SSpreferences.queue_write(preferences)
+ return TRUE
diff --git a/code/modules/client/preferences/serialization/preferences_player.dm b/code/modules/client/preferences/serialization/preferences_player.dm
new file mode 100644
index 0000000000000..7b0ae4ea5c981
--- /dev/null
+++ b/code/modules/client/preferences/serialization/preferences_player.dm
@@ -0,0 +1,71 @@
+/// A cache for player preferences data
+/datum/preferences_holder/preferences_player
+ pref_type = PREFERENCE_PLAYER
+
+/datum/preferences_holder/preferences_player/proc/load_from_database(datum/preferences/prefs)
+ if(IS_GUEST_KEY(prefs.parent.key) || !query_data(prefs)) // Query direct, otherwise create informed defaults
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (preference.preference_type != pref_type)
+ continue
+ preference_data[preference.db_key] = preference.deserialize(preference.create_informed_default_value(prefs), prefs)\
+ // Give the developers +1 sanity points
+ if(Debugger?.enabled)
+ prefs.update_preference(/datum/preference/toggle/sound_ambience, FALSE)
+ prefs.update_preference(/datum/preference/toggle/sound_ship_ambience, FALSE)
+ prefs.update_preference(/datum/preference/toggle/sound_lobby, FALSE)
+ // TODO tgui-prefs initialize undatumized prefs here?
+ // no idiot put that in save_preferences() if this returns false.
+ return FALSE
+ return TRUE
+
+/datum/preferences_holder/preferences_player/proc/query_data(datum/preferences/prefs)
+ if(!SSdbcore.IsConnected())
+ return FALSE
+ var/datum/DBQuery/Q = SSdbcore.NewQuery(
+ "SELECT CAST(preference_tag AS CHAR) AS ptag, preference_value FROM [format_table_name("preferences")] WHERE ckey=:ckey",
+ list("ckey" = prefs.parent.ckey)
+ )
+ if(!Q.warn_execute())
+ qdel(Q)
+ return FALSE
+ var/any_data = FALSE
+ while(Q.NextRow())
+ var/db_key = Q.item[1]
+ var/value = Q.item[2]
+ var/datum/preference/preference = GLOB.preference_entries_by_key[db_key]
+ if(!preference)
+ // TODO tgui-prefs clean out database and re-enable this
+ //CRASH("Unknown preference tag in database: [db_key] for ckey [prefs.parent.ckey]")
+ continue
+ preference_data[db_key] = isnull(value) ? null : preference.deserialize(value, prefs)
+ any_data = TRUE
+ qdel(Q)
+ return any_data
+
+/datum/preferences_holder/preferences_player/proc/write_to_database(datum/preferences/prefs)
+ . = write_data(prefs)
+ dirty_prefs.Cut() // clear all dirty preferences
+
+/datum/preferences_holder/preferences_player/proc/write_data(datum/preferences/prefs)
+ if(!SSdbcore.IsConnected() || IS_GUEST_KEY(prefs.parent.key))
+ return FALSE
+ var/list/sql_inserts = list()
+ for(var/db_key in dirty_prefs)
+ if(!(db_key in preference_data))
+ CRASH("Invalid db_key found in dirty preferences list: [db_key].")
+ var/datum/preference/preference = GLOB.preference_entries_by_key[db_key]
+ if(!istype(preference))
+ CRASH("Could not find preference with db_key [db_key] when writing to database.")
+ sql_inserts += list(list(
+ "ckey" = prefs.parent.ckey,
+ "preference_tag" = db_key,
+ "preference_value" = preference.serialize(preference_data[db_key])
+ ))
+ if(!length(sql_inserts)) // nothing to update
+ return TRUE
+ var/success = SSdbcore.MassInsert(format_table_name("preferences"), sql_inserts, duplicate_key = TRUE, warn = TRUE)
+ if(!success)
+ to_chat(prefs.parent, "Failed to save your player preferences. Please inform the server operator or a maintainer of this error.")
+ prefs.fail_state = success
+ return success
diff --git a/code/modules/client/preferences/submodules/preference_assets.dm b/code/modules/client/preferences/submodules/preference_assets.dm
new file mode 100644
index 0000000000000..2ddec52277cd1
--- /dev/null
+++ b/code/modules/client/preferences/submodules/preference_assets.dm
@@ -0,0 +1,100 @@
+/// Assets generated from `/datum/preference` icons
+/datum/asset/spritesheet/preferences
+ name = PREFERENCE_SHEET_NORMAL
+ early = TRUE
+ cross_round_cachable = TRUE
+
+/datum/asset/spritesheet/preferences/create_spritesheets()
+ create_preferences_spritesheet(src, name)
+
+/proc/create_preferences_spritesheet(datum/asset/spritesheet/sheet, sheet_key)
+ for (var/preference_key in GLOB.preference_entries_by_key)
+ var/datum/preference/choiced/preference = GLOB.preference_entries_by_key[preference_key]
+ if (!istype(preference))
+ continue
+
+ if (!preference.should_generate_icons)
+ continue
+
+ if(preference.preference_spritesheet != sheet_key)
+ continue
+
+ var/list/choices = preference.get_choices_serialized()
+ for (var/preference_value in choices)
+ var/create_icon_of = choices[preference_value]
+ var/icon/icon
+ var/icon_state
+ if (ispath(create_icon_of, /atom))
+ var/atom/atom_icon_source = create_icon_of
+ icon = initial(atom_icon_source.icon)
+ icon_state = initial(atom_icon_source.icon_state)
+ else if (isicon(create_icon_of))
+ icon = create_icon_of
+ else
+ CRASH("[create_icon_of] is an invalid preference value (from [preference_key]:[preference_value]).")
+
+ sheet.Insert(preference.get_spritesheet_key(preference_value), icon, icon_state)
+
+/// This "large" spritesheet helps reduce mount lag from large PNG files.
+/datum/asset/spritesheet/preferences_large
+ name = PREFERENCE_SHEET_LARGE
+ early = TRUE
+ cross_round_cachable = TRUE
+
+/datum/asset/spritesheet/preferences_large/create_spritesheets()
+ create_preferences_spritesheet(src, name)
+
+/// This "huge" spritesheet helps reduce mount lag from huge PNG files.
+/datum/asset/spritesheet/preferences_huge
+ name = PREFERENCE_SHEET_HUGE
+ early = TRUE
+ cross_round_cachable = TRUE
+
+
+/datum/asset/spritesheet/preferences_huge/create_spritesheets()
+ // if someone ever hits this limit, you need to delete the game
+ // just delete it, it's too big. It needs to end (the year is probably 2053 or something)
+ create_preferences_spritesheet(src, name)
+
+/// Returns the key that will be used in the spritesheet for a given value.
+/datum/preference/proc/get_spritesheet_key(value)
+ return "[db_key]___[sanitize_css_class_name(value)]"
+
+/datum/asset/spritesheet/preferences_loadout
+ name = "preferences_loadout"
+ early = TRUE
+
+/datum/asset/spritesheet/preferences_loadout/create_spritesheets()
+ for(var/gear_id in GLOB.gear_datums)
+ var/datum/gear/G = GLOB.gear_datums[gear_id]
+ var/icon/regular_icon = get_display_icon_for(G.path)
+ if(!regular_icon)
+ continue
+ Insert("loadout_gear___[gear_id]", regular_icon)
+ var/icon/skirt_icon = get_display_icon_for(G.skirt_path)
+ if(!skirt_icon)
+ continue
+ Insert("loadout_gear___[gear_id]_skirt", skirt_icon)
+
+/// Sends information needed for shared details on individual preferences
+/datum/asset/json/preferences
+ name = "preferences"
+
+/datum/asset/json/preferences/generate()
+ var/list/preference_data = list()
+
+ for (var/middleware_type in subtypesof(/datum/preference_middleware))
+ var/datum/preference_middleware/middleware = new middleware_type
+ var/data = middleware.get_constant_data()
+ if (!isnull(data))
+ preference_data[middleware.key] = data
+
+ qdel(middleware)
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference_entry = GLOB.preference_entries[preference_type]
+ var/data = preference_entry.compile_constant_data()
+ if (!isnull(data))
+ preference_data[preference_entry.db_key] = data
+
+ return preference_data
diff --git a/code/modules/client/preferences/submodules/preference_character_preview.dm b/code/modules/client/preferences/submodules/preference_character_preview.dm
new file mode 100644
index 0000000000000..775b506628eba
--- /dev/null
+++ b/code/modules/client/preferences/submodules/preference_character_preview.dm
@@ -0,0 +1,137 @@
+/datum/preferences/proc/create_character_preview_view()
+ if(istype(character_preview_view))
+ return
+ character_preview_view = new(null, src)
+ if(parent)
+ character_preview_view.register_to_client(parent)
+ // HACK: Without this the character starts out really tiny because of https://www.byond.com/forum/post/2873835
+ // You can fix it by updating the atom's appearance (in any way), so let's just do something unexpensive and change its name!
+ character_preview_view.rename_byond_bug_moment()
+
+/datum/preferences/proc/render_new_preview_appearance(mob/living/carbon/human/dummy/mannequin)
+ var/datum/job/preview_job = get_highest_priority_job()
+
+ // Silicons only need a very basic preview since there is no customization for them.
+ if (istype(preview_job, /datum/job/ai))
+ return image('icons/mob/ai.dmi', icon_state = resolve_ai_icon_sync(read_character_preference(/datum/preference/choiced/ai_core_display)), dir = SOUTH)
+ if (istype(preview_job, /datum/job/cyborg))
+ return image('icons/mob/robots.dmi', icon_state = "robot", dir = SOUTH)
+
+ // Set up the dummy for its photoshoot
+ apply_prefs_to(mannequin, TRUE)
+ // Normalize size, since it doesn't scale properly in the preview.
+ mannequin.dna.features["body_size"] = "Normal"
+ mannequin.dna.update_body_size()
+
+ if(preview_job)
+ mannequin.job = preview_job.title
+ preview_job.equip(mannequin, TRUE, preference_source = parent)
+ preview_job.after_spawn(mannequin, mannequin, preference_source = parent, on_dummy = TRUE)
+ else
+ apply_loadout_to_mob(mannequin, mannequin, preference_source = parent, on_dummy = TRUE)
+
+ COMPILE_OVERLAYS(mannequin)
+ return mannequin.appearance
+
+// This is necessary because you can open the set preferences menu before
+// the atoms SS is done loading.
+INITIALIZE_IMMEDIATE(/atom/movable/screen/map_view/character_preview_view)
+
+/// A preview of a character for use in the preferences menu
+/atom/movable/screen/map_view/character_preview_view
+ name = "character_preview"
+ del_on_map_removal = FALSE
+ mouse_opacity = MOUSE_OPACITY_TRANSPARENT
+
+ /// The body that is displayed
+ var/mob/living/carbon/human/dummy/body
+
+ /// The preferences this refers to
+ var/datum/preferences/preferences
+
+ var/list/plane_masters = list()
+
+ /// List of clients with this registered to it.
+ var/list/viewing_clients = list()
+
+/atom/movable/screen/map_view/character_preview_view/Initialize(mapload, datum/preferences/preferences)
+ . = ..()
+
+ assigned_map = "character_preview_[REF(src)]"
+ set_position(1, 1)
+
+ src.preferences = preferences
+
+/atom/movable/screen/map_view/character_preview_view/Destroy()
+ QDEL_NULL(body)
+
+ for (var/plane_master in plane_masters)
+ qdel(plane_master)
+
+ for(var/client/C as anything in viewing_clients)
+ C?.clear_map(assigned_map)
+
+ preferences?.character_preview_view = null
+
+ viewing_clients = null
+ plane_masters = null
+ preferences = null
+
+ return ..()
+
+/// I know this looks stupid but it fixes a really important bug. https://www.byond.com/forum/post/2873835
+/// Also the mouse opacity blocks this from being visible ever
+/atom/movable/screen/map_view/character_preview_view/proc/rename_byond_bug_moment()
+ #if MIN_COMPILER_VERSION > 514
+ #warn Remove 514 BYOND bug workaround in preferences character preview
+ #endif
+ spawn(0) // Using spawn() to avoid addtimer() since it doesn't fire during init
+ while(TRUE)
+ name = name == "character_preview" ? "character_preview_1" : "character_preview"
+ stoplag(1 SECONDS)
+
+/// Updates the currently displayed body
+/atom/movable/screen/map_view/character_preview_view/proc/update_body()
+ if (isnull(body))
+ create_body()
+ else
+ body.wipe_state()
+ body.appearance = preferences.render_new_preview_appearance(body)
+ // Force map view to update as well
+ name = name == "character_preview" ? "character_preview_1" : "character_preview"
+
+/atom/movable/screen/map_view/character_preview_view/proc/create_body()
+ vis_contents.Cut()
+ QDEL_NULL(body)
+
+ body = new
+
+ // Without this, it doesn't show up in the menu
+ body.appearance_flags &= ~KEEP_TOGETHER
+ body.wipe_state() // cleanup the body immediately since it spawns with overlays, AI and cyborgs will retain them.
+ vis_contents += body
+
+/// Registers the relevant map objects to a client
+/atom/movable/screen/map_view/character_preview_view/proc/register_to_client(client/client)
+ if(client in viewing_clients)
+ return
+ if(!length(plane_masters))
+ for(var/plane in subtypesof(/atom/movable/screen/plane_master) - /atom/movable/screen/plane_master/blackness)
+ var/atom/movable/screen/plane_master/instance = new plane()
+ instance.assigned_map = assigned_map
+ if(instance.blend_mode_override)
+ instance.blend_mode = instance.blend_mode_override
+ instance.del_on_map_removal = FALSE
+ instance.screen_loc = "[assigned_map]:CENTER"
+ plane_masters += instance
+ viewing_clients += client
+ client.register_map_obj(src)
+ for(var/plane_master in plane_masters)
+ client.register_map_obj(plane_master)
+
+/// Unregisters the relevant map objects to a client
+/atom/movable/screen/map_view/character_preview_view/proc/unregister_from_client(client/client)
+ if(!istype(client) || !(client in viewing_clients))
+ return
+ client.clear_map(assigned_map)
+ viewing_clients -= client
diff --git a/code/modules/client/preferences/submodules/preference_jobs.dm b/code/modules/client/preferences/submodules/preference_jobs.dm
new file mode 100644
index 0000000000000..f384538b987ac
--- /dev/null
+++ b/code/modules/client/preferences/submodules/preference_jobs.dm
@@ -0,0 +1,35 @@
+/datum/preferences/proc/set_job_preference_level(datum/job/job, level)
+ if (!job)
+ return FALSE
+
+ if (level == JP_HIGH)
+ var/datum/job/overflow_role = SSjob.overflow_role
+ var/overflow_role_title = initial(overflow_role.title)
+
+ for(var/other_job in job_preferences)
+ if(job_preferences[other_job] == JP_HIGH)
+ // Overflow role needs to go to NEVER, not medium!
+ if(other_job == overflow_role_title)
+ job_preferences[other_job] = null
+ else
+ job_preferences[other_job] = JP_MEDIUM
+
+ if(level == null)
+ job_preferences -= job.title
+ else
+ job_preferences[job.title] = level
+ mark_undatumized_dirty_character()
+
+ return TRUE
+
+/// Returns what job is marked as highest
+/datum/preferences/proc/get_highest_priority_job()
+ var/datum/job/preview_job
+ var/highest_pref = 0
+
+ for(var/job in job_preferences)
+ if(job_preferences[job] > highest_pref)
+ preview_job = SSjob.GetJob(job)
+ highest_pref = job_preferences[job]
+
+ return preview_job
diff --git a/code/modules/client/preferences/submodules/preference_keybindings.dm b/code/modules/client/preferences/submodules/preference_keybindings.dm
new file mode 100644
index 0000000000000..3381ad5536e5b
--- /dev/null
+++ b/code/modules/client/preferences/submodules/preference_keybindings.dm
@@ -0,0 +1,31 @@
+/// Inverts the key_bindings list such that it can be used for key_bindings_by_key
+/datum/preferences/proc/get_key_bindings_by_key(list/key_bindings)
+ var/list/output = list()
+
+ for (var/action in key_bindings)
+ for (var/key in key_bindings[action])
+ LAZYADD(output[key], action)
+
+ return output
+
+/datum/preferences/proc/set_key_bindings(list/_key_bindings)
+ if(!istype(_key_bindings))
+ return
+ key_bindings = _key_bindings
+ key_bindings_by_key = get_key_bindings_by_key(key_bindings)
+ mark_undatumized_dirty_player()
+
+/datum/preferences/proc/set_default_key_bindings(save = FALSE)
+ key_bindings = deep_copy_list(GLOB.keybindings_by_name_to_key)
+ key_bindings_by_key = get_key_bindings_by_key(key_bindings)
+ if(save)
+ mark_undatumized_dirty_player()
+
+/datum/preferences/proc/set_keybind(keybind_name, hotkeys)
+ if (!(keybind_name in GLOB.keybindings_by_name))
+ return FALSE
+ if(!islist(hotkeys))
+ return
+ key_bindings[keybind_name] = hotkeys
+ key_bindings_by_key = get_key_bindings_by_key(key_bindings)
+ mark_undatumized_dirty_player()
diff --git a/code/modules/client/preferences/submodules/preference_loadouts.dm b/code/modules/client/preferences/submodules/preference_loadouts.dm
new file mode 100644
index 0000000000000..ee424a5f1c2d0
--- /dev/null
+++ b/code/modules/client/preferences/submodules/preference_loadouts.dm
@@ -0,0 +1,28 @@
+/// Handles adding and removing donator items from clients
+/datum/preferences/proc/handle_donator_items()
+ var/datum/loadout_category/DLC = GLOB.loadout_categories["Donator"] // stands for donator loadout category but the other def for DLC works too xD
+ if(!CONFIG_GET(flag/donator_items)) // donator items are only accesibile by servers with a patreon
+ return
+ if(IS_PATRON(parent.ckey) || is_admin(parent.ckey))
+ var/any_changed = FALSE
+ for(var/gear_id in DLC.gear)
+ var/datum/gear/AG = DLC.gear[gear_id]
+ if(AG.id in purchased_gear)
+ continue
+ any_changed = TRUE
+ purchased_gear += AG.id
+ AG.purchase(parent)
+ if(any_changed)
+ mark_undatumized_dirty_player()
+ else if(length(purchased_gear) || length(equipped_gear))
+ var/any_changed = FALSE
+ for(var/gear_id in DLC.gear)
+ var/datum/gear/RG = DLC.gear[gear_id]
+ if(!(RG.id in purchased_gear) && !(RG.id in equipped_gear))
+ continue
+ any_changed = TRUE
+ equipped_gear -= RG.id
+ purchased_gear -= RG.id
+ if(any_changed)
+ mark_undatumized_dirty_player()
+ mark_undatumized_dirty_character()
diff --git a/code/modules/client/preferences/submodules/preference_randomization.dm b/code/modules/client/preferences/submodules/preference_randomization.dm
new file mode 100644
index 0000000000000..bfbc5887ba5e1
--- /dev/null
+++ b/code/modules/client/preferences/submodules/preference_randomization.dm
@@ -0,0 +1,39 @@
+/// Fully randomizes everything in the character.
+/datum/preferences/proc/randomize_appearance_prefs(randomize_flags = ALL)
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (!preference.included_in_randomization_flags(randomize_flags))
+ continue
+
+ if (preference.is_randomizable())
+ write_preference(preference, preference.create_random_value(src))
+
+/// Randomizes the character according to preferences.
+/datum/preferences/proc/apply_character_randomization_prefs(antag_override = FALSE)
+ switch (read_character_preference(/datum/preference/choiced/random_body))
+ if (RANDOM_ANTAG_ONLY)
+ if (!antag_override)
+ return
+
+ if (RANDOM_DISABLED)
+ return
+
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (should_randomize(preference, antag_override))
+ write_preference(preference, preference.create_random_value(src))
+
+/// Returns the default `randomise` variable ouptut
+/datum/preferences/proc/get_default_randomization()
+ var/list/default_randomization = list()
+
+ for (var/preference_key in GLOB.preference_entries_by_key)
+ var/datum/preference/preference = GLOB.preference_entries_by_key[preference_key]
+ if (preference.is_randomizable() && preference.randomize_by_default)
+ default_randomization[preference_key] = RANDOM_ENABLED
+
+ return default_randomization
+
+/// Sanitizes the preferences, applies the randomization prefs, and then applies the preference to the human mob.
+/// This is generally used over apply_prefs_to, since it respects the player's body/antag randomization
+/datum/preferences/proc/safe_transfer_prefs_to(mob/living/carbon/human/character, icon_updates = TRUE, is_antag = FALSE)
+ apply_character_randomization_prefs(is_antag)
+ apply_prefs_to(character, icon_updates)
diff --git a/code/modules/client/preferences2/character_save.dm b/code/modules/client/preferences2/character_save.dm
deleted file mode 100644
index 394dfe3c47215..0000000000000
--- a/code/modules/client/preferences2/character_save.dm
+++ /dev/null
@@ -1,513 +0,0 @@
-/**
- * # Character Save Datum
- *
- * Datum to hold a character save which is put into a list on [/datum/preferences].
- * All of these are loaded on login.
- */
-/datum/character_save
- // Meta Vars //
- /// Slot number. Used for internal tracking. The slot number also correspnds to the number of slots in the characters list
- var/slot_number = 0
- /// Is this slot locked, likely due to not having enough character slots available
- var/slot_locked = FALSE
- /// Was this loaded from the DB? (This is used to decide on INSERT or UPDATE queries)
- var/from_db = FALSE
-
- // Character Related Vars //
- /// Species datum
-
- var/real_name
- var/be_random_name = FALSE
- var/be_random_body = FALSE
- var/gender = MALE
- var/age = 30
- var/underwear = "Nude"
- var/underwear_color = "000"
- var/undershirt = "Nude"
- var/socks = "Nude" // how lewd
- var/helmet_style = HELMET_DEFAULT
- var/backbag = DBACKPACK
- var/jumpsuit_style = PREF_SUIT
- var/hair_style = "Bald"
- var/hair_color = "000"
- var/gradient_color = "000"
- var/gradient_style = "None"
- var/facial_hair_style = "Shaved"
- var/facial_hair_color = "000"
- var/skin_tone = "caucasian1"
- var/eye_color = "000"
- var/datum/species/pref_species
- var/list/features = list(
- "body_size" = "Normal",
- "mcolor" = "FFF",
- "ethcolor" = "9c3030",
- "tail_lizard" = "Smooth",
- "tail_human" = "None",
- "snout" = "Round",
- "horns" = "None",
- "ears" = "None",
- "wings" = "None",
- "frills" = "None",
- "spines" = "None",
- "body_markings" = "None",
- "legs" = "Normal Legs",
- "moth_wings" = "Plain",
- "moth_antennae" = "Plain",
- "moth_markings" = "None",
- "ipc_screen" = "Blue",
- "ipc_antenna" = "None",
- "ipc_chassis" = "Morpheus Cyberkinetics(Greyscale)",
- "insect_type" = "Common Fly",
- "psyphoza_cap" = "Portobello",
- "apid_antenna" = "Curled",
- "apid_stripes" = "Thick",
- "apid_headstripes" = "Thick",
- "body_model" = MALE
- )
- var/list/custom_names = list()
- var/preferred_ai_core_display = "Blue"
- var/preferred_security_department = SEC_DEPT_RANDOM
- var/list/all_quirks = list()
- var/list/job_preferences = list()
- var/list/equipped_gear = list()
- var/joblessrole = BERANDOMJOB //defaults to 1 for fewer assistants
- var/uplink_spawn_loc = UPLINK_PDA
- var/list/role_preferences_character = list()
-
-
-/datum/character_save/New()
- real_name = get_default_name()
- for(var/custom_name_id in GLOB.preferences_custom_names)
- custom_names[custom_name_id] = get_default_name(custom_name_id)
-
-#define SAFE_READ_QUERY(idx, target) if(Q.item[idx]) target = Q.item[idx]
-
-/datum/character_save/proc/handle_query(datum/DBQuery/Q)
- from_db = TRUE
-
- // please keep these in numerical order I beg
- //Species
- var/species_id
- SAFE_READ_QUERY(2, species_id)
-
- if(!species_id) // There was no species ID saved, make it random
- species_id = pick(GLOB.roundstart_races)
-
- var/newtype = GLOB.species_list[species_id]
-
- if(!newtype) // The species ID doesn't exist in the species list, make it random
- newtype = GLOB.species_list[pick(GLOB.roundstart_races)]
-
- pref_species = new newtype
-
- if(!pref_species) // there are no roundstart species enabled. Time to die
- pref_species = new /datum/species/human
- if(!length(GLOB.roundstart_races))
- CRASH("There are no roundstart races enabled! You must enable at least one for the character setup to function.")
-
- //Character
- SAFE_READ_QUERY(3, real_name)
- SAFE_READ_QUERY(4, be_random_name)
- SAFE_READ_QUERY(5, be_random_body)
- SAFE_READ_QUERY(6, gender)
- SAFE_READ_QUERY(7, age)
- SAFE_READ_QUERY(8, hair_color)
- SAFE_READ_QUERY(9, gradient_color)
- SAFE_READ_QUERY(10, facial_hair_color)
- SAFE_READ_QUERY(11, eye_color)
- SAFE_READ_QUERY(12, skin_tone)
- SAFE_READ_QUERY(13, hair_style)
- SAFE_READ_QUERY(14, gradient_style)
- SAFE_READ_QUERY(15, facial_hair_style)
- SAFE_READ_QUERY(16, underwear)
- SAFE_READ_QUERY(17, underwear_color)
- SAFE_READ_QUERY(18, undershirt)
- SAFE_READ_QUERY(19, socks)
- SAFE_READ_QUERY(20, backbag)
- SAFE_READ_QUERY(21, jumpsuit_style)
- SAFE_READ_QUERY(22, uplink_spawn_loc)
-
- var/tmp_features
- SAFE_READ_QUERY(23, tmp_features)
- if(tmp_features)
- features = json_decode(tmp_features)
-
- if(!CONFIG_GET(flag/join_with_mutant_humans) && !species_id != "felinid") // felinids arent mutant humans anymore i guess
- features["tail_human"] = "none"
- features["ears"] = "none"
-
- //Custom names
- var/tmp_names
- SAFE_READ_QUERY(24, tmp_names)
- custom_names = json_decode(tmp_names)
-
- SAFE_READ_QUERY(25, helmet_style)
-
- SAFE_READ_QUERY(26, preferred_ai_core_display)
- SAFE_READ_QUERY(27, preferred_security_department)
-
- //Jobs
- SAFE_READ_QUERY(28, joblessrole)
- //Load prefs
- var/job_tmp
- SAFE_READ_QUERY(29, job_tmp)
- job_preferences = json_decode(job_tmp)
-
- //Quirks
- var/quirks_tmp
- SAFE_READ_QUERY(30, quirks_tmp)
- all_quirks = json_decode(quirks_tmp)
-
- // Gear
- var/loadout_tmp
- SAFE_READ_QUERY(31, loadout_tmp)
- equipped_gear = json_decode(loadout_tmp)
-
- // Role prefs
- var/role_preferences_character_tmp
- SAFE_READ_QUERY(32, role_preferences_character_tmp)
- role_preferences_character = json_decode(role_preferences_character_tmp)
-
- //Sanitize. Please dont put query reads below this point. Please.
-
- real_name = reject_bad_name(real_name, pref_species.allow_numbers_in_name)
- gender = sanitize_gender(gender)
- real_name ||= pref_species.random_name(gender, TRUE)
-
- for(var/custom_name_id in GLOB.preferences_custom_names)
- var/namedata = GLOB.preferences_custom_names[custom_name_id]
- custom_names[custom_name_id] = reject_bad_name(custom_names[custom_name_id],namedata["allow_numbers"])
- if(!custom_names[custom_name_id])
- custom_names[custom_name_id] = get_default_name(custom_name_id)
-
- if(!features["mcolor"] || features["mcolor"] == "#000")
- features["mcolor"] = pick("FFFFFF","7F7F7F", "7FFF7F", "7F7FFF", "FF7F7F", "7FFFFF", "FF7FFF", "FFFF7F")
-
- if(!features["ethcolor"] || features["ethcolor"] == "#000")
- features["ethcolor"] = GLOB.color_list_ethereal[pick(GLOB.color_list_ethereal)]
-
- // Keep it updated
- if(!helmet_style || !(helmet_style in list(HELMET_DEFAULT, HELMET_MK2, HELMET_PROTECTIVE)))
- helmet_style = HELMET_DEFAULT
-
- be_random_name = sanitize_integer(be_random_name, 0, 1, initial(be_random_name))
- be_random_body = sanitize_integer(be_random_body, 0, 1, initial(be_random_body))
-
- hair_style = sanitize_inlist(hair_style, GLOB.hair_styles_list)
- facial_hair_style = sanitize_inlist(facial_hair_style, GLOB.facial_hair_styles_list)
- underwear = sanitize_inlist(underwear, GLOB.underwear_list)
- undershirt = sanitize_inlist(undershirt, GLOB.undershirt_list)
- features["body_model"] = sanitize_gender(features["body_model"], FALSE, FALSE, gender == FEMALE ? FEMALE : MALE)
- socks = sanitize_inlist(socks, GLOB.socks_list)
- age = sanitize_integer(age, AGE_MIN, AGE_MAX, initial(age))
- hair_color = sanitize_hexcolor(hair_color, 3, 0)
- facial_hair_color = sanitize_hexcolor(facial_hair_color, 3, 0)
- gradient_style = sanitize_inlist(gradient_style, GLOB.hair_gradients_list, "None")
- gradient_color = sanitize_hexcolor(gradient_color, 3, 0)
- underwear_color = sanitize_hexcolor(underwear_color, 3, 0)
- eye_color = sanitize_hexcolor(eye_color, 3, 0)
- skin_tone = sanitize_inlist(skin_tone, GLOB.skin_tones)
- backbag = sanitize_inlist(backbag, GLOB.backbaglist, initial(backbag))
- jumpsuit_style = sanitize_inlist(jumpsuit_style, GLOB.jumpsuitlist, initial(jumpsuit_style))
- uplink_spawn_loc = sanitize_inlist(uplink_spawn_loc, GLOB.uplink_spawn_loc_list_save, initial(uplink_spawn_loc))
- features["body_size"] = sanitize_inlist(features["body_size"], GLOB.body_sizes, "Normal")
- features["mcolor"] = sanitize_hexcolor(features["mcolor"], 3, 0)
- features["ethcolor"] = copytext_char(features["ethcolor"], 1, 7)
- features["tail_lizard"] = sanitize_inlist(features["tail_lizard"], GLOB.tails_list_lizard)
- features["tail_human"] = sanitize_inlist(features["tail_human"], GLOB.tails_list_human, "None")
- features["snout"] = sanitize_inlist(features["snout"], GLOB.snouts_list)
- features["horns"] = sanitize_inlist(features["horns"], GLOB.horns_list)
- features["ears"] = sanitize_inlist(features["ears"], GLOB.ears_list, "None")
- features["frills"] = sanitize_inlist(features["frills"], GLOB.frills_list)
- features["spines"] = sanitize_inlist(features["spines"], GLOB.spines_list)
- features["body_markings"] = sanitize_inlist(features["body_markings"], GLOB.body_markings_list)
- features["feature_lizard_legs"] = sanitize_inlist(features["legs"], GLOB.legs_list, "Normal Legs")
- features["moth_wings"] = sanitize_inlist(features["moth_wings"], GLOB.moth_wings_roundstart_list, "Plain")
- features["moth_antennae"] = sanitize_inlist(features["moth_antennae"], GLOB.moth_antennae_roundstart_list, "Plain")
- features["moth_markings"] = sanitize_inlist(features["moth_markings"], GLOB.moth_markings_roundstart_list, "None")
- features["ipc_screen"] = sanitize_inlist(features["ipc_screen"], GLOB.ipc_screens_list)
- features["ipc_antenna"] = sanitize_inlist(features["ipc_antenna"], GLOB.ipc_antennas_list)
- features["ipc_chassis"] = sanitize_inlist(features["ipc_chassis"], GLOB.ipc_chassis_list)
- features["insect_type"] = sanitize_inlist(features["insect_type"], GLOB.insect_type_list)
- features["psyphoza_cap"] = sanitize_inlist(features["psyphoza_cap"], GLOB.psyphoza_cap_list)
- features["apid_antenna"] = sanitize_inlist(features["apid_antenna"], GLOB.apid_antenna_list)
- features["apid_stripes"] = sanitize_inlist(features["apid_stripes"], GLOB.apid_stripes_list)
- features["apid_headstripes"] = sanitize_inlist(features["apid_headstripes"], GLOB.apid_headstripes_list)
-
- //Validate species forced mutant parts
- for(var/forced_part in pref_species.forced_features)
- //Get the forced type
- var/forced_type = pref_species.forced_features[forced_part]
- //Apply the forced bodypart.
- features[forced_part] = forced_type
-
- joblessrole = sanitize_integer(joblessrole, 1, 3, initial(joblessrole))
- //Validate job prefs
- for(var/j in job_preferences)
- if(job_preferences[j] != JP_LOW && job_preferences[j] != JP_MEDIUM && job_preferences[j] != JP_HIGH)
- job_preferences -= j
-
- all_quirks = SANITIZE_LIST(all_quirks)
- role_preferences_character = SANITIZE_LIST(role_preferences_character)
- // Remove any invalid entries
- for(var/preference in role_preferences_character)
- var/path = text2path(preference)
- var/datum/role_preference/entry = GLOB.role_preference_entries[path]
- if(istype(entry) && entry.per_character)
- continue
- role_preferences_character -= preference
-
- return TRUE
-
-#undef SAFE_READ_QUERY
-
-/datum/character_save/proc/randomise(gender_override)
- if(gender_override)
- gender = gender_override
- else
- gender = pick(MALE,FEMALE)
- underwear = random_underwear(gender)
- underwear_color = random_short_color()
- undershirt = random_undershirt(gender)
- socks = random_socks()
- skin_tone = random_skin_tone()
- hair_style = random_hair_style(gender)
- facial_hair_style = random_facial_hair_style(gender)
- hair_color = random_short_color()
- facial_hair_color = hair_color
- eye_color = random_eye_color()
- if(!pref_species)
- var/datum/species/spath = GLOB.species_list[pick(GLOB.roundstart_races)]
- pref_species = new spath
- features = random_features()
- if(gender)
- features["body_model"] = pick(MALE,FEMALE)
- age = rand(AGE_MIN,AGE_MAX)
-
-/datum/character_save/proc/update_preview_icon(client/parent)
- if(!parent)
- CRASH("Someone called update_preview_icon() without passing a client.")
- // Determine what job is marked as 'High' priority, and dress them up as such.
- var/datum/job/previewJob
- var/highest_pref = 0
- for(var/job in job_preferences)
- if(job_preferences[job] > highest_pref)
- previewJob = SSjob.GetJob(job)
- highest_pref = job_preferences[job]
-
- if(previewJob)
- // Silicons only need a very basic preview since there is no customization for them.
- if(istype(previewJob,/datum/job/ai))
- parent.show_character_previews(image('icons/mob/ai.dmi', icon_state = resolve_ai_icon(preferred_ai_core_display), dir = SOUTH))
- return
- if(istype(previewJob,/datum/job/cyborg))
- parent.show_character_previews(image('icons/mob/robots.dmi', icon_state = "robot", dir = SOUTH))
- return
-
- // Set up the dummy for its photoshoot
- var/mob/living/carbon/human/dummy/mannequin = generate_or_wait_for_human_dummy(DUMMY_HUMAN_SLOT_PREFERENCES)
- copy_to(mannequin)
-
- if(previewJob)
- mannequin.job = previewJob.title
- previewJob.equip(mannequin, TRUE, preference_source = parent)
-
- COMPILE_OVERLAYS(mannequin)
- parent.show_character_previews(new /mutable_appearance(mannequin))
- unset_busy_human_dummy(DUMMY_HUMAN_SLOT_PREFERENCES)
-
-/datum/character_save/proc/save(client/C, async = TRUE)
- if(!SSdbcore.IsConnected())
- return
-
- if(IS_GUEST_KEY(C.ckey))
- return
-
- // Get ready for a disgusting query
- var/datum/DBQuery/insert_query = SSdbcore.NewQuery({"
- REPLACE INTO [format_table_name("characters")] (
- slot,
- ckey,
- species,
- real_name,
- name_is_always_random,
- body_is_always_random,
- gender,
- age,
- hair_color,
- gradient_color,
- facial_hair_color,
- eye_color,
- skin_tone,
- hair_style_name,
- gradient_style,
- facial_style_name,
- underwear,
- underwear_color,
- undershirt,
- socks,
- backbag,
- jumpsuit_style,
- uplink_loc,
- features,
- custom_names,
- helmet_style,
- preferred_ai_core_display,
- preferred_security_department,
- joblessrole,
- job_preferences,
- all_quirks,
- equipped_gear,
- role_preferences
- ) VALUES (
- :slot,
- :ckey,
- :species,
- :real_name,
- :name_is_always_random,
- :body_is_always_random,
- :gender,
- :age,
- :hair_color,
- :gradient_color,
- :facial_hair_color,
- :eye_color,
- :skin_tone,
- :hair_style_name,
- :gradient_style,
- :facial_style_name,
- :underwear,
- :underwear_color,
- :undershirt,
- :socks,
- :backbag,
- :jumpsuit_style,
- :uplink_loc,
- :features,
- :custom_names,
- :helmet_style,
- :preferred_ai_core_display,
- :preferred_security_department,
- :joblessrole,
- :job_preferences,
- :all_quirks,
- :equipped_gear,
- :role_preferences
- )
- "}, list(
- // Now for the above but in a fucking monsterous list
- "slot" = slot_number,
- "ckey" = C.ckey,
- "species" = pref_species.id,
- "real_name" = real_name,
- "name_is_always_random" = be_random_name,
- "body_is_always_random" = be_random_body,
- "gender" = gender,
- "age" = age,
- "hair_color" = hair_color,
- "gradient_color" = gradient_color,
- "facial_hair_color" = facial_hair_color,
- "eye_color" = eye_color,
- "skin_tone" = skin_tone,
- "hair_style_name" = hair_style,
- "gradient_style" = gradient_style,
- "facial_style_name" = facial_hair_style,
- "underwear" = underwear,
- "underwear_color" = underwear_color,
- "undershirt" = undershirt,
- "socks" = socks,
- "backbag" = backbag,
- "jumpsuit_style" = jumpsuit_style,
- "uplink_loc" = uplink_spawn_loc,
- "features" = json_encode(features),
- "custom_names" = json_encode(custom_names),
- "helmet_style" = helmet_style,
- "preferred_ai_core_display" = preferred_ai_core_display,
- "preferred_security_department" = preferred_security_department,
- "joblessrole" = joblessrole,
- "job_preferences" = json_encode(job_preferences),
- "all_quirks" = json_encode(all_quirks),
- "equipped_gear" = json_encode(equipped_gear),
- "role_preferences" = json_encode(role_preferences_character)
- ))
-
- if(!insert_query.warn_execute())
- to_chat(usr, "Failed to save your character. Please inform the server operator.")
- qdel(insert_query)
- return
-
- qdel(insert_query)
-
- // We defo exist in the DB now
- from_db = TRUE
-
-/datum/character_save/proc/copy_to(mob/living/carbon/human/character, icon_updates = 1, roundstart_checks = TRUE)
- if(be_random_name)
- real_name = pref_species.random_name(gender)
-
- if(be_random_body)
- randomise(gender)
-
- if(roundstart_checks)
- if(CONFIG_GET(flag/humans_need_surnames) && (pref_species.id == SPECIES_HUMAN))
- var/firstspace = findtext(real_name, " ")
- var/name_length = length(real_name)
- if(!firstspace) //we need a surname
- real_name += " [pick(GLOB.last_names)]"
- else if(firstspace == name_length)
- real_name += "[pick(GLOB.last_names)]"
-
- character.real_name = real_name
- character.name = character.real_name
-
- character.gender = gender
- character.age = age
-
- character.eye_color = eye_color
- var/obj/item/organ/eyes/organ_eyes = character.getorgan(/obj/item/organ/eyes)
- if(organ_eyes)
- if(!initial(organ_eyes.eye_color))
- organ_eyes.eye_color = eye_color
- organ_eyes.old_eye_color = eye_color
-
- character.hair_color = hair_color
- character.gradient_color = gradient_color
- character.gradient_style = gradient_style
- character.facial_hair_color = facial_hair_color
- character.skin_tone = skin_tone
- character.underwear = underwear
- character.underwear_color = underwear_color
- character.undershirt = undershirt
- character.socks = socks
-
- character.backbag = backbag
- character.jumpsuit_style = jumpsuit_style
-
- var/datum/species/chosen_species
- chosen_species = pref_species.type
- if(!roundstart_checks || (pref_species.id in GLOB.roundstart_races) || pref_species.check_no_hard_check())
- chosen_species = pref_species.type
- else
- chosen_species = /datum/species/human
- pref_species = new /datum/species/human
- save(usr.client, async = FALSE) // This entire proc is called a lot at roundstart, and we dont want to lag that
-
-
- character.dna.features = features.Copy()
- character.set_species(chosen_species, icon_update = FALSE, pref_load = TRUE)
-
- //Because of how set_species replaces all bodyparts with new ones, hair needs to be set AFTER species.
- character.dna.real_name = character.real_name
- character.hair_color = hair_color
- character.facial_hair_color = facial_hair_color
-
- character.hair_style = hair_style
- character.facial_hair_style = facial_hair_style
-
- if("tail_lizard" in pref_species.default_features)
- character.dna.species.mutant_bodyparts |= "tail_lizard"
-
- if(icon_updates)
- character.update_body()
- character.update_hair()
- character.update_body_parts(TRUE)
diff --git a/code/modules/client/preferences2/preferences2.dm b/code/modules/client/preferences2/preferences2.dm
deleted file mode 100644
index 8e363efd5a8d9..0000000000000
--- a/code/modules/client/preferences2/preferences2.dm
+++ /dev/null
@@ -1,275 +0,0 @@
-/*
-
- Preferences 2 - Now with 100% more database
-
- This system evicts savefiles and uses the database for all playerdata storage.
-
- All "user" preferences must be stored as tags in the `SS13_preferences` table.
- Any boolean toggle preference (Show/Hide Deadchat for example) **MUST** be a bitflag toggle stored as a single toggles integer.
- This is to cut down on the amount of useless columns when you can pack up to 24 binary toggles into a single integer.
- NOTE: You cant go above 24. BYOND loses precision and you then break everything.
-
- All character customisation preferences must be saved in the `SS13_characters` table.
- All properties for character customisation must be tacked onto a [/datum/character_save], not the main prefs datum
-
- Failure to comply with this will result in being screamed at.
- - AA07
-
-*/
-
-// Defines for list sanity
-#define READPREF_RAW(target, tag) if(prefmap[tag]) target = prefmap[tag]
-#define READPREF_INT(target, tag) if(prefmap[tag]) target = text2num(prefmap[tag])
-
-// Did you know byond has try/catch? We use it here so malformed JSON doesnt break the entire loading system
-#define READPREF_JSONDEC(target, tag) \
- try {\
- if(prefmap[tag]) {\
- target = json_decode(prefmap[tag]);\
- };\
- } catch {\
- pass();\
- } // we dont need error handling where were going
-
-/datum/preferences/proc/load_from_database()
- . = FALSE
- if(!SSdbcore.IsConnected())
- // TODO - Loading of sane defaults
- if (!length(key_bindings))
- key_bindings = deep_copy_list(GLOB.keybinding_list_by_key)
- if(Debugger?.enabled)
- toggles &= ~(PREFTOGGLE_SOUND_AMBIENCE | PREFTOGGLE_SOUND_SHIP_AMBIENCE | PREFTOGGLE_SOUND_LOBBY)
- return
-
- var/datum/DBQuery/read_player_data = SSdbcore.NewQuery(
- "SELECT CAST(preference_tag AS CHAR) AS ptag, preference_value FROM [format_table_name("preferences")] WHERE ckey=:ckey",
- list("ckey" = parent.ckey)
- )
-
- // K:pref tag | V:pref value
- var/list/prefmap = list() // dont rename this. trust me.
-
- if(!read_player_data.Execute())
- qdel(read_player_data)
- return
- else
- while(read_player_data.NextRow())
- prefmap[read_player_data.item[1]] = read_player_data.item[2]
- qdel(read_player_data)
-
- //general preferences
- READPREF_INT(default_slot, PREFERENCE_TAG_DEFAULT_SLOT)
- READPREF_INT(chat_toggles, PREFERENCE_TAG_CHAT_TOGGLES)
- READPREF_INT(toggles, PREFERENCE_TAG_TOGGLES)
- READPREF_INT(toggles2, PREFERENCE_TAG_TOGGLES2)
- READPREF_INT(clientfps, PREFERENCE_TAG_CLIENTFPS)
- READPREF_INT(parallax, PREFERENCE_TAG_PARALLAX)
- READPREF_INT(pixel_size, PREFERENCE_TAG_PIXELSIZE)
- READPREF_INT(tip_delay, PREFERENCE_TAG_TIP_DELAY)
-
- READPREF_RAW(asaycolor, PREFERENCE_TAG_ASAY_COLOUR)
- READPREF_RAW(ooccolor, PREFERENCE_TAG_OOC_COLOUR)
- READPREF_RAW(lastchangelog, PREFERENCE_TAG_LAST_CL)
- READPREF_RAW(UI_style, PREFERENCE_TAG_UI_STYLE)
- READPREF_RAW(outline_color, PREFERENCE_TAG_OUTLINE_COLOUR)
- READPREF_RAW(see_balloon_alerts, PREFERENCE_TAG_BALLOON_ALERTS)
- READPREF_RAW(scaling_method, PREFERENCE_TAG_SCALING_METHOD)
- READPREF_RAW(ghost_form, PREFERENCE_TAG_GHOST_FORM)
- READPREF_RAW(ghost_orbit, PREFERENCE_TAG_GHOST_ORBIT)
- READPREF_RAW(ghost_accs, PREFERENCE_TAG_GHOST_ACCS)
- READPREF_RAW(ghost_others, PREFERENCE_TAG_GHOST_OTHERS)
- READPREF_RAW(pda_theme, PREFERENCE_TAG_PDA_THEME)
- READPREF_RAW(pda_color, PREFERENCE_TAG_PDA_COLOUR)
- READPREF_RAW(pai_name, PREFERENCE_TAG_PAI_NAME)
- READPREF_RAW(pai_description, PREFERENCE_TAG_PAI_DESCRIPTION)
- READPREF_RAW(pai_comment, PREFERENCE_TAG_PAI_COMMENT)
-
- READPREF_JSONDEC(ignoring, PREFERENCE_TAG_IGNORING)
- READPREF_JSONDEC(key_bindings, PREFERENCE_TAG_KEYBINDS)
- READPREF_JSONDEC(purchased_gear, PREFERENCE_TAG_PURCHASED_GEAR)
- READPREF_JSONDEC(role_preferences, PREFERENCE_TAG_ROLE_PREFERENCES)
-
- //Sanitize
- asaycolor = sanitize_ooccolor(sanitize_hexcolor(asaycolor, 6, TRUE, initial(asaycolor)))
- ooccolor = sanitize_ooccolor(sanitize_hexcolor(ooccolor, 6, TRUE, initial(ooccolor)))
- lastchangelog = sanitize_text(lastchangelog, initial(lastchangelog))
- UI_style = sanitize_inlist(UI_style, GLOB.available_ui_styles, GLOB.available_ui_styles[1])
-
- default_slot = sanitize_integer(default_slot, TRUE, TRUE_MAX_SAVE_SLOTS, initial(default_slot))
- toggles = sanitize_integer(toggles, FALSE, INFINITY, initial(toggles)) // yes
- toggles2 = sanitize_integer(toggles2, FALSE, INFINITY, initial(toggles2))
- clientfps = sanitize_integer(clientfps, FALSE, 1000, FALSE)
- parallax = sanitize_integer(parallax, PARALLAX_INSANE, PARALLAX_DISABLE, null)
-
- pixel_size = sanitize_float(pixel_size, PIXEL_SCALING_AUTO, PIXEL_SCALING_3X, 0.5, initial(pixel_size))
- scaling_method = sanitize_text(scaling_method, initial(scaling_method))
- ghost_form = sanitize_inlist(ghost_form, GLOB.ghost_forms, initial(ghost_form))
- ghost_orbit = sanitize_inlist(ghost_orbit, GLOB.ghost_orbits, initial(ghost_orbit))
- ghost_accs = sanitize_inlist(ghost_accs, GLOB.ghost_accs_options, GHOST_ACCS_DEFAULT_OPTION)
- ghost_others = sanitize_inlist(ghost_others, GLOB.ghost_others_options, GHOST_OTHERS_DEFAULT_OPTION)
- role_preferences = SANITIZE_LIST(role_preferences)
- // Remove any invalid entries
- for(var/preference in role_preferences)
- var/path = text2path(preference)
- var/datum/role_preference/entry = GLOB.role_preference_entries[path]
- if(istype(entry) && !entry.per_character)
- continue
- role_preferences -= preference
-
- pda_theme = sanitize_inlist(pda_theme, GLOB.ntos_device_themes_default_content, initial(pda_theme))
- pda_color = sanitize_hexcolor(pda_color, 6, TRUE, initial(pda_color))
-
- pai_name = sanitize_text(pai_name, initial(pai_name))
- pai_description = sanitize_text(pai_description, initial(pai_description))
- pai_comment = sanitize_text(pai_comment, initial(pai_comment))
-
- key_bindings = sanitize_islist(key_bindings, deep_copy_list(GLOB.keybinding_list_by_key))
- if (!length(key_bindings))
- key_bindings = deep_copy_list(GLOB.keybinding_list_by_key)
- else
- var/any_changed = FALSE
- for(var/key_name in GLOB.keybindings_by_name)
- var/datum/keybinding/keybind = GLOB.keybindings_by_name[key_name]
- var/in_binds = FALSE
- for(var/bind in key_bindings)
- if(key_name in key_bindings[bind])
- in_binds = TRUE
- break
- if(in_binds)
- continue
- any_changed = TRUE
- if(!islist(key_bindings[keybind.key]))
- key_bindings[keybind.key] = list(key_name)
- else
- key_bindings[keybind.key] += key_name
- if(any_changed)
- save_keybinds()
-
- if(!purchased_gear)
- purchased_gear = list()
-
- return TRUE
-
-#undef READPREF_RAW
-#undef READPREF_INT
-#undef READPREF_JSONDEC
-
-// OH BOY MORE MACRO ABUSE
-#define PREP_WRITEPREF_RAW(value, tag) write_queries += SSdbcore.NewQuery("INSERT INTO [format_table_name("preferences")] (ckey, preference_tag, preference_value) VALUES (:ckey, :ptag, :pvalue) ON DUPLICATE KEY UPDATE preference_value=:pvalue2", list("ckey" = parent.ckey, "ptag" = tag, "pvalue" = value, "pvalue2" = value))
-#define PREP_WRITEPREF_JSONENC(value, tag) PREP_WRITEPREF_RAW(json_encode(value), tag)
-
-/datum/preferences/proc/save_keybinds()
- var/list/datum/DBQuery/write_queries = list()
- PREP_WRITEPREF_JSONENC(key_bindings, PREFERENCE_TAG_KEYBINDS)
- SSdbcore.QuerySelect(write_queries, TRUE, TRUE)
-
-// Writes all prefs to the DB
-/datum/preferences/proc/save_preferences()
- if(!SSdbcore.IsConnected())
- return
-
- if(IS_GUEST_KEY(parent.ckey))
- return
-
- var/list/datum/DBQuery/write_queries = list() // do not rename this you muppet
-
- //general preferences
- PREP_WRITEPREF_RAW(default_slot, PREFERENCE_TAG_DEFAULT_SLOT)
- PREP_WRITEPREF_RAW(chat_toggles, PREFERENCE_TAG_CHAT_TOGGLES)
- PREP_WRITEPREF_RAW(toggles, PREFERENCE_TAG_TOGGLES)
- PREP_WRITEPREF_RAW(toggles2, PREFERENCE_TAG_TOGGLES2)
- PREP_WRITEPREF_RAW(clientfps, PREFERENCE_TAG_CLIENTFPS)
- PREP_WRITEPREF_RAW(parallax, PREFERENCE_TAG_PARALLAX)
- PREP_WRITEPREF_RAW(pixel_size, PREFERENCE_TAG_PIXELSIZE)
- PREP_WRITEPREF_RAW(tip_delay, PREFERENCE_TAG_TIP_DELAY)
- PREP_WRITEPREF_RAW(pda_theme, PREFERENCE_TAG_PDA_THEME)
- PREP_WRITEPREF_RAW(pda_color, PREFERENCE_TAG_PDA_COLOUR)
-
- PREP_WRITEPREF_RAW(asaycolor, PREFERENCE_TAG_ASAY_COLOUR)
- PREP_WRITEPREF_RAW(ooccolor, PREFERENCE_TAG_OOC_COLOUR)
- PREP_WRITEPREF_RAW(lastchangelog, PREFERENCE_TAG_LAST_CL)
- PREP_WRITEPREF_RAW(UI_style, PREFERENCE_TAG_UI_STYLE)
- PREP_WRITEPREF_RAW(outline_color, PREFERENCE_TAG_OUTLINE_COLOUR)
- PREP_WRITEPREF_RAW(see_balloon_alerts, PREFERENCE_TAG_BALLOON_ALERTS)
- PREP_WRITEPREF_RAW(scaling_method, PREFERENCE_TAG_SCALING_METHOD)
- PREP_WRITEPREF_RAW(ghost_form, PREFERENCE_TAG_GHOST_FORM)
- PREP_WRITEPREF_RAW(ghost_orbit, PREFERENCE_TAG_GHOST_ORBIT)
- PREP_WRITEPREF_RAW(ghost_accs, PREFERENCE_TAG_GHOST_ACCS)
- PREP_WRITEPREF_RAW(ghost_others, PREFERENCE_TAG_GHOST_OTHERS)
- PREP_WRITEPREF_RAW(pai_name, PREFERENCE_TAG_PAI_NAME)
- PREP_WRITEPREF_RAW(pai_description, PREFERENCE_TAG_PAI_DESCRIPTION)
- PREP_WRITEPREF_RAW(pai_comment, PREFERENCE_TAG_PAI_COMMENT)
-
- PREP_WRITEPREF_JSONENC(ignoring, PREFERENCE_TAG_IGNORING)
- PREP_WRITEPREF_JSONENC(key_bindings, PREFERENCE_TAG_KEYBINDS)
- PREP_WRITEPREF_JSONENC(purchased_gear, PREFERENCE_TAG_PURCHASED_GEAR)
- PREP_WRITEPREF_JSONENC(role_preferences, PREFERENCE_TAG_ROLE_PREFERENCES)
-
- // QuerySelect can execute many queries at once. That name is dumb but w/e
- SSdbcore.QuerySelect(write_queries, TRUE, TRUE)
-
-#undef PREP_WRITEPREF_RAW
-#undef PREP_WRITEPREF_JSONENC
-
-
-// Get ready for a disgusting SQL query
-/datum/preferences/proc/load_characters()
- // Do NOT remove stuff from the start of this query. Only append to the end.
- // If you delete an entry, god help you as you have to update all the indexes
- var/datum/DBQuery/read_chars = SSdbcore.NewQuery({"
- SELECT
- slot,
- species,
- real_name,
- name_is_always_random,
- body_is_always_random,
- gender,
- age,
- hair_color,
- gradient_color,
- facial_hair_color,
- eye_color,
- skin_tone,
- hair_style_name,
- gradient_style,
- facial_style_name,
- underwear,
- underwear_color,
- undershirt,
- socks,
- backbag,
- jumpsuit_style,
- uplink_loc,
- features,
- custom_names,
- helmet_style,
- preferred_ai_core_display,
- preferred_security_department,
- joblessrole,
- job_preferences,
- all_quirks,
- equipped_gear,
- role_preferences
- FROM [format_table_name("characters")] WHERE
- ckey=:ckey
- "}, list("ckey" = parent.ckey))
-
- if(!read_chars.warn_execute())
- qdel(read_chars)
- return
-
- var/char_loaded = FALSE
- while(read_chars.NextRow())
- var/idx = read_chars.item[1]
- var/datum/character_save/CS = character_saves[idx]
- CS.handle_query(read_chars)
- char_loaded = TRUE
-
- qdel(read_chars)
- check_usable_slots()
- return char_loaded
-
-
-/datum/preferences/proc/check_usable_slots()
- for(var/datum/character_save/CS as anything in character_saves)
- CS.slot_locked = (CS.slot_number > max_usable_slots)
diff --git a/code/modules/client/preferences_toggles.dm b/code/modules/client/preferences_toggles.dm
deleted file mode 100644
index 9658ed1be8a43..0000000000000
--- a/code/modules/client/preferences_toggles.dm
+++ /dev/null
@@ -1,448 +0,0 @@
-/client/verb/game_preferences()
- set name = "Game Preferences"
- set category = "Preferences"
- set desc = "Open Game Preferences Window"
- prefs.current_tab = 1
- prefs.ShowChoices(usr)
-
-/client/verb/character_preferences()
- set name = "Character Preferences"
- set category = "Preferences"
- set desc = "Open Character Preferences Window"
- prefs.current_tab = 0
- prefs.ShowChoices(usr)
-
-/client/verb/toggle_ghost_ears()
- set name = "Show/Hide GhostEars"
- set category = "Preferences"
- set desc = "See All Speech"
- prefs.chat_toggles ^= CHAT_GHOSTEARS
- to_chat(usr, "As a ghost, you will now [(prefs.chat_toggles & CHAT_GHOSTEARS) ? "see all speech in the world" : "only see speech from nearby mobs"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Ears", "[prefs.chat_toggles & CHAT_GHOSTEARS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_ghost_sight()
- set name = "Show/Hide GhostSight"
- set category = "Preferences"
- set desc = "See All Emotes"
- prefs.chat_toggles ^= CHAT_GHOSTSIGHT
- to_chat(usr, "As a ghost, you will now [(prefs.chat_toggles & CHAT_GHOSTSIGHT) ? "see all emotes in the world" : "only see emotes from nearby mobs"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Sight", "[prefs.chat_toggles & CHAT_GHOSTSIGHT ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_ghost_whispers()
- set name = "Show/Hide GhostWhispers"
- set category = "Preferences"
- set desc = "See All Whispers"
- prefs.chat_toggles ^= CHAT_GHOSTWHISPER
- to_chat(usr, "As a ghost, you will now [(prefs.chat_toggles & CHAT_GHOSTWHISPER) ? "see all whispers in the world" : "only see whispers from nearby mobs"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Whispers", "[prefs.chat_toggles & CHAT_GHOSTWHISPER ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_ghost_radio()
- set name = "Show/Hide GhostRadio"
- set category = "Preferences"
- set desc = "See All Radio Chatter"
- prefs.chat_toggles ^= CHAT_GHOSTRADIO
- to_chat(usr, "As a ghost, you will now [(prefs.chat_toggles & CHAT_GHOSTRADIO) ? "see radio chatter" : "not see radio chatter"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Radio", "[prefs.chat_toggles & CHAT_GHOSTRADIO ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! //social experiment, increase the generation whenever you copypaste this shamelessly GENERATION 1
-
-/client/verb/toggle_ghost_pda()
- set name = "Show/Hide GhostPDA"
- set category = "Preferences"
- set desc = "See All PDA Messages"
- prefs.chat_toggles ^= CHAT_GHOSTPDA
- to_chat(usr, "As a ghost, you will now [(prefs.chat_toggles & CHAT_GHOSTPDA) ? "see all pda messages in the world" : "only see pda messages from nearby mobs"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost PDA", "[prefs.chat_toggles & CHAT_GHOSTPDA ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_ghost_laws()
- set name = "Show/Hide GhostLaws"
- set category = "Preferences"
- set desc = "See All Law Changes"
- prefs.chat_toggles ^= CHAT_GHOSTLAWS
- to_chat(usr, "As a ghost, you will now [(prefs.chat_toggles & CHAT_GHOSTLAWS) ? "be notified of all law changes" : "no longer be notified of law changes"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Laws", "[prefs.chat_toggles & CHAT_GHOSTLAWS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_ghost_follow()
- set name = "Toggle NPC GhostFollow"
- set category = "Preferences"
- set desc = "Toggle the visibility of the follow button for NPC messages and emotes."
- prefs.chat_toggles ^= CHAT_GHOSTFOLLOWMINDLESS
- to_chat(usr, "As a ghost, you will [(prefs.chat_toggles & CHAT_GHOSTFOLLOWMINDLESS) ? "now" : "no longer"] see the follow button for NPC mobs.")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle NPC GhostFollow", "[prefs.chat_toggles & CHAT_GHOSTFOLLOWMINDLESS ? "Enabled" : "Disabled"]"))
-
-//please be aware that the following two verbs have inverted stat output, so that "Toggle Deathrattle|1" still means you activated it
-/client/verb/toggle_deathrattle()
- set name = "Toggle Deathrattle"
- set category = "Preferences"
- set desc = "Death"
- prefs.toggles ^= PREFTOGGLE_DISABLE_DEATHRATTLE
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & PREFTOGGLE_DISABLE_DEATHRATTLE) ? "no longer" : "now"] get messages when a sentient mob dies.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Deathrattle", "[!(prefs.toggles & PREFTOGGLE_DISABLE_DEATHRATTLE) ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, maybe you should spend some time reading the comments.
-
-/client/verb/toggle_arrivalrattle()
- set name = "Toggle Arrivalrattle"
- set category = "Preferences"
- set desc = "New Player Arrival"
- prefs.toggles ^= PREFTOGGLE_DISABLE_ARRIVALRATTLE
- to_chat(usr, "You will [(prefs.toggles & PREFTOGGLE_DISABLE_ARRIVALRATTLE) ? "no longer" : "now"] get messages when someone joins the station.")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Arrivalrattle", "[!(prefs.toggles & PREFTOGGLE_DISABLE_ARRIVALRATTLE) ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, maybe you should rethink where your life went so wrong.
-
-/client/verb/toggletitlemusic()
- set name = "Hear/Silence Lobby Music"
- set category = "Preferences"
- set desc = "Hear Music In Lobby"
- prefs.toggles ^= PREFTOGGLE_SOUND_LOBBY
- prefs.save_preferences()
- if(prefs.toggles & PREFTOGGLE_SOUND_LOBBY)
- to_chat(usr, "You will now hear music in the game lobby.")
- if(isnewplayer(usr))
- playtitlemusic()
- else
- to_chat(usr, "You will no longer hear music in the game lobby.")
- usr.stop_sound_channel(CHANNEL_LOBBYMUSIC)
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Lobby Music", "[prefs.toggles & PREFTOGGLE_SOUND_LOBBY ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/togglemidis()
- set name = "Hear/Silence Midis"
- set category = "Preferences"
- set desc = "Hear Admin Triggered Sounds (Midis)"
- prefs.toggles ^= PREFTOGGLE_SOUND_MIDI
- prefs.save_preferences()
- if(prefs.toggles & PREFTOGGLE_SOUND_MIDI)
- to_chat(usr, "You will now hear any sounds uploaded by admins.")
- else
- to_chat(usr, "You will no longer hear sounds uploaded by admins")
- usr.stop_sound_channel(CHANNEL_ADMIN)
- var/client/C = usr.client
- C?.tgui_panel?.stop_music()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Hearing Midis", "[prefs.toggles & PREFTOGGLE_SOUND_MIDI ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_instruments()
- set name = "Hear/Silence Instruments"
- set category = "Preferences"
- set desc = "Hear In-game Instruments"
- prefs.toggles ^= PREFTOGGLE_SOUND_INSTRUMENTS
- prefs.save_preferences()
- if(prefs.toggles & PREFTOGGLE_SOUND_INSTRUMENTS)
- to_chat(usr, "You will now hear people playing musical instruments.")
- else
- to_chat(usr, "You will no longer hear musical instruments.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Instruments", "[prefs.toggles & PREFTOGGLE_SOUND_INSTRUMENTS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/Toggle_Soundscape()
- set name = "Hear/Silence Ambience"
- set category = "Preferences"
- set desc = "Hear Ambient Sound Effects"
- prefs.toggles ^= PREFTOGGLE_SOUND_AMBIENCE
- prefs.save_preferences()
- if(prefs.toggles & PREFTOGGLE_SOUND_AMBIENCE)
- to_chat(usr, "You will now hear ambient sounds.")
- else
- to_chat(usr, "You will no longer hear ambient sounds.")
- usr.stop_sound_channel(CHANNEL_AMBIENT_EFFECTS)
- usr.stop_sound_channel(CHANNEL_AMBIENT_MUSIC)
- usr.stop_sound_channel(CHANNEL_BUZZ)
- usr.client.buzz_playing = FALSE
- usr.client.update_ambience_pref()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ambience", "[usr.client.prefs.toggles & PREFTOGGLE_SOUND_AMBIENCE ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_ship_ambience()
- set name = "Hear/Silence Ship Ambience"
- set category = "Preferences"
- set desc = "Hear Ship Ambience Roar"
- prefs.toggles ^= PREFTOGGLE_SOUND_SHIP_AMBIENCE
- prefs.save_preferences()
- if(prefs.toggles & PREFTOGGLE_SOUND_SHIP_AMBIENCE)
- to_chat(usr, "You will now hear ship ambience.")
- else
- to_chat(usr, "You will no longer hear ship ambience.")
- usr.stop_sound_channel(CHANNEL_BUZZ)
- usr.client.buzz_playing = FALSE
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ship Ambience", "[usr.client.prefs.toggles & PREFTOGGLE_SOUND_SHIP_AMBIENCE ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, I bet you read this comment expecting to see the same thing :^)
-
-/client/verb/toggle_soundtrack()
- set name = "Hear/Silence Soundtrack"
- set category = "Preferences"
- set desc = "Hear Soundtrack Songs"
- prefs.toggles2 ^= PREFTOGGLE_2_SOUNDTRACK
- prefs.save_preferences()
- if(prefs.toggles2 & PREFTOGGLE_2_SOUNDTRACK)
- to_chat(usr, "You will now hear soundtrack songs.")
- usr.play_current_soundtrack()
- else
- to_chat(usr, "You will no longer hear soundtrack songs.")
- usr.stop_sound_channel(CHANNEL_SOUNDTRACK)
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Soundtrack", "[usr.client.prefs.toggles2 & PREFTOGGLE_2_SOUNDTRACK ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, I bet you read this comment expecting to see the same thing :^)
-
-
-/client/verb/toggle_announcement_sound()
- set name = "Hear/Silence Announcements"
- set category = "Preferences"
- set desc = "Hear Announcement Sound"
- prefs.toggles ^= PREFTOGGLE_SOUND_ANNOUNCEMENTS
- to_chat(usr, "You will now [(prefs.toggles & PREFTOGGLE_SOUND_ANNOUNCEMENTS) ? "hear announcement sounds" : "no longer hear announcements"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Announcement Sound", "[prefs.toggles & PREFTOGGLE_SOUND_ANNOUNCEMENTS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/stop_client_sounds()
- set name = "Stop Sounds"
- set category = "Preferences"
- set desc = "Stop Current Sounds"
- SEND_SOUND(usr, sound(null))
- tgui_panel?.stop_music()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Stop Self Sounds")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/listen_ooc()
- set name = "Show/Hide OOC"
- set category = "Preferences"
- set desc = "Show OOC Chat"
- prefs.chat_toggles ^= CHAT_OOC
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.chat_toggles & CHAT_OOC) ? "now" : "no longer"] see messages on the OOC channel.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Seeing OOC", "[prefs.chat_toggles & CHAT_OOC ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/listen_bank_card()
- set name = "Show/Hide Income Updates"
- set category = "Preferences"
- set desc = "Show or hide updates to your income"
- prefs.chat_toggles ^= CHAT_BANKCARD
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.chat_toggles & CHAT_BANKCARD) ? "now" : "no longer"] be notified when you get paid.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Income Notifications", "[(prefs.chat_toggles & CHAT_BANKCARD) ? "Enabled" : "Disabled"]"))
-
-GLOBAL_LIST_INIT(ghost_forms, sort_list(list("ghost","ghostking","ghostian2","skeleghost","ghost_red","ghost_black", \
- "ghost_blue","ghost_yellow","ghost_green","ghost_pink", \
- "ghost_cyan","ghost_dblue","ghost_dred","ghost_dgreen", \
- "ghost_dcyan","ghost_grey","ghost_dyellow","ghost_dpink", "ghost_purpleswirl","ghost_funkypurp","ghost_pinksherbert","ghost_blazeit",\
- "ghost_mellow","ghost_rainbow","ghost_camo","ghost_fire", "catghost")))
-
-/client/proc/pick_form()
- if(!is_content_unlocked())
- alert("This setting is for accounts with BYOND premium only.")
- return
- var/new_form = input(src, "Thanks for supporting BYOND - Choose your ghostly form:","Thanks for supporting BYOND",null) as null|anything in GLOB.ghost_forms
- if(new_form)
- prefs.ghost_form = new_form
- prefs.save_preferences()
- if(isobserver(mob))
- var/mob/dead/observer/O = mob
- O.update_icon(new_form = new_form)
-
-GLOBAL_LIST_INIT(ghost_orbits, list(GHOST_ORBIT_CIRCLE,GHOST_ORBIT_TRIANGLE,GHOST_ORBIT_SQUARE,GHOST_ORBIT_HEXAGON,GHOST_ORBIT_PENTAGON))
-
-/client/proc/pick_ghost_orbit()
- if(!is_content_unlocked())
- alert("This setting is for accounts with BYOND premium only.")
- return
- var/new_orbit = input(src, "Thanks for supporting BYOND - Choose your ghostly orbit:","Thanks for supporting BYOND",null) as null|anything in GLOB.ghost_orbits
- if(new_orbit)
- prefs.ghost_orbit = new_orbit
- prefs.save_preferences()
- if(isobserver(mob))
- var/mob/dead/observer/O = mob
- O.ghost_orbit = new_orbit
-
-/client/proc/pick_ghost_accs()
- var/new_ghost_accs = alert("Do you want your ghost to show full accessories where possible, hide accessories but still use the directional sprites where possible, or also ignore the directions and stick to the default sprites?",,"full accessories", "only directional sprites", "default sprites")
- if(new_ghost_accs)
- switch(new_ghost_accs)
- if("full accessories")
- prefs.ghost_accs = GHOST_ACCS_FULL
- if("only directional sprites")
- prefs.ghost_accs = GHOST_ACCS_DIR
- if("default sprites")
- prefs.ghost_accs = GHOST_ACCS_NONE
- prefs.save_preferences()
- if(isobserver(mob))
- var/mob/dead/observer/O = mob
- O.update_icon()
-
-/client/verb/pick_ghost_customization()
- set name = "Ghost Customization"
- set category = "Preferences"
- set desc = "Customize your ghastly appearance."
- if(is_content_unlocked())
- switch(alert("Which setting do you want to change?",,"Ghost Form","Ghost Orbit","Ghost Accessories"))
- if("Ghost Form")
- pick_form()
- if("Ghost Orbit")
- pick_ghost_orbit()
- if("Ghost Accessories")
- pick_ghost_accs()
- else
- pick_ghost_accs()
-
-/client/verb/pick_ghost_others()
- set name = "Ghosts of Others"
- set category = "Preferences"
- set desc = "Change display settings for the ghosts of other players."
- var/new_ghost_others = alert("Do you want the ghosts of others to show up as their own setting, as their default sprites or always as the default white ghost?",,"Their Setting", "Default Sprites", "White Ghost")
- if(new_ghost_others)
- switch(new_ghost_others)
- if("Their Setting")
- prefs.ghost_others = GHOST_OTHERS_THEIR_SETTING
- if("Default Sprites")
- prefs.ghost_others = GHOST_OTHERS_DEFAULT_SPRITE
- if("White Ghost")
- prefs.ghost_others = GHOST_OTHERS_SIMPLE
- prefs.save_preferences()
- if(isobserver(mob))
- var/mob/dead/observer/O = mob
- O.update_sight()
-
-/client/verb/toggle_intent_style()
- set name = "Toggle Intent Selection Style"
- set category = "Preferences"
- set desc = "Toggle between directly clicking the desired intent or clicking to rotate through."
- prefs.toggles ^= PREFTOGGLE_INTENT_STYLE
- to_chat(src, "[(prefs.toggles & PREFTOGGLE_INTENT_STYLE) ? "Clicking directly on intents selects them." : "Clicking on intents rotates selection clockwise."]")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Intent Selection", "[prefs.toggles & PREFTOGGLE_INTENT_STYLE ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/verb/toggle_ghost_hud_pref()
- set name = "Toggle Ghost HUD"
- set category = "Preferences"
- set desc = "Hide/Show Ghost HUD"
-
- prefs.toggles2 ^= PREFTOGGLE_2_GHOST_HUD
- to_chat(src, "Ghost HUD will now be [(prefs.toggles2 & PREFTOGGLE_2_GHOST_HUD) ? "visible" : "hidden"].")
- prefs.save_preferences()
- if(isobserver(mob))
- mob.hud_used.show_hud()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost HUD", "[(prefs.toggles2 & PREFTOGGLE_2_GHOST_HUD) ? "Enabled" : "Disabled"]"))
-
-/client/verb/toggle_show_credits()
- set name = "Toggle Credits"
- set category = "Preferences"
- set desc = "Hide/Show Credits"
-
- prefs.toggles2 ^= PREFTOGGLE_2_SHOW_CREDITS
- to_chat(src, "Credits will now be [prefs.toggles2 & PREFTOGGLE_2_SHOW_CREDITS ? "visible" : "hidden"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Credits", "[prefs.toggles2 & PREFTOGGLE_2_SHOW_CREDITS ? "Enabled" : "Disabled"]"))
-
-/client/verb/toggle_inquisition() // warning: unexpected inquisition
- set name = "Toggle Inquisitiveness"
- set desc = "Sets whether your ghost examines everything on click by default"
- set category = "Preferences"
-
- prefs.toggles2 ^= PREFTOGGLE_2_GHOST_INQUISITIVENESS
- prefs.save_preferences()
- if(prefs.toggles2 & PREFTOGGLE_2_GHOST_INQUISITIVENESS)
- to_chat(src, "You will now examine everything you click on.")
- else
- to_chat(src, "You will no longer examine things you click on.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Inquisitiveness", "[(prefs.toggles2 & PREFTOGGLE_2_GHOST_INQUISITIVENESS) ? "Enabled" : "Disabled"]"))
-
-//Admin Preferences
-/client/proc/toggleadminhelpsound()
- set name = "Hear/Silence Adminhelps"
- set category = "Prefs - Admin"
- set desc = "Toggle hearing a notification when admin PMs are received"
- if(!holder)
- return
- prefs.toggles ^= PREFTOGGLE_SOUND_ADMINHELP
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & PREFTOGGLE_SOUND_ADMINHELP) ? "now" : "no longer"] hear a sound when adminhelps arrive.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Adminhelp Sound", "[prefs.toggles & PREFTOGGLE_SOUND_ADMINHELP ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggleadminalertsound()
- set name = "Hear/Silence Admin alerts"
- set category = "Prefs - Admin"
- set desc = "Toggle hearing a notification when various admin alerts happen"
- if(!holder)
- return
- prefs.toggles ^= PREFTOGGLE_2_SOUND_ADMINALERT
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & PREFTOGGLE_2_SOUND_ADMINALERT) ? "now" : "no longer"] hear a sound when an admin alert shows up.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Admin alert Sound", "[prefs.toggles & PREFTOGGLE_2_SOUND_ADMINALERT ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggleannouncelogin()
- set name = "Do/Don't Announce Login"
- set category = "Prefs - Admin"
- set desc = "Toggle if you want an announcement to admins when you login during a round"
- if(!holder)
- return
- prefs.toggles ^= PREFTOGGLE_ANNOUNCE_LOGIN
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & PREFTOGGLE_ANNOUNCE_LOGIN) ? "now" : "no longer"] have an announcement to other admins when you login.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Login Announcement", "[prefs.toggles & PREFTOGGLE_ANNOUNCE_LOGIN ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggle_hear_radio()
- set name = "Show/Hide Radio Chatter"
- set category = "Prefs - Admin"
- set desc = "Toggle seeing radiochatter from nearby radios and speakers"
- if(!holder)
- return
- prefs.chat_toggles ^= CHAT_RADIO
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.chat_toggles & CHAT_RADIO) ? "now" : "no longer"] see radio chatter from nearby radios or speakers")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Radio Chatter", "[prefs.chat_toggles & CHAT_RADIO ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/deadchat()
- set name = "Show/Hide Deadchat"
- set category = "Prefs - Admin"
- set desc ="Toggles seeing deadchat"
- if(!holder)
- return
- prefs.chat_toggles ^= CHAT_DEAD
- prefs.save_preferences()
- to_chat(src, "You will [(prefs.chat_toggles & CHAT_DEAD) ? "now" : "no longer"] see deadchat.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Deadchat Visibility", "[prefs.chat_toggles & CHAT_DEAD ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggleprayers()
- set name = "Show/Hide Prayers"
- set category = "Prefs - Admin"
- set desc = "Toggles seeing prayers"
- if(!holder)
- return
- prefs.chat_toggles ^= CHAT_PRAYER
- prefs.save_preferences()
- to_chat(src, "You will [(prefs.chat_toggles & CHAT_PRAYER) ? "now" : "no longer"] see prayerchat.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Prayer Visibility", "[prefs.chat_toggles & CHAT_PRAYER ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-
-/client/proc/toggle_prayer_sound()
- set name = "Hear/Silence Prayer Sounds"
- set category = "Prefs - Admin"
- set desc = "Hear Prayer Sounds"
- if(!holder)
- return
- prefs.toggles ^= PREFTOGGLE_SOUND_PRAYERS
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & PREFTOGGLE_SOUND_PRAYERS) ? "now" : "no longer"] hear a sound when prayers arrive.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Prayer Sounds", "[prefs.toggles & PREFTOGGLE_SOUND_PRAYERS ? "Enabled" : "Disabled"]"))
-
-/client/proc/colorasay()
- set name = "Set Admin Say Color"
- set category = "Prefs - Admin"
- set desc = "Set the color of your ASAY messages"
- if(!holder)
- return
- if(!CONFIG_GET(flag/allow_admin_asaycolor))
- to_chat(src, "Custom Asay color is currently disabled by the server.")
- return
- var/new_asaycolor = tgui_color_picker(src, "Please select your ASAY color.", "ASAY color", prefs.asaycolor)
- if(new_asaycolor)
- prefs.asaycolor = sanitize_ooccolor(new_asaycolor)
- prefs.save_preferences()
- SSblackbox.record_feedback("tally", "admin_verb", 1, "Set ASAY Color")
- return
-
-/client/proc/resetasaycolor()
- set name = "Reset your Admin Say Color"
- set desc = "Returns your ASAY Color to default"
- set category = "Prefs - Admin"
- if(!holder)
- return
- if(!CONFIG_GET(flag/allow_admin_asaycolor))
- to_chat(src, "Custom Asay color is currently disabled by the server.")
- return
- prefs.asaycolor = initial(prefs.asaycolor)
- prefs.save_preferences()
diff --git a/code/modules/client/verbs/etips.dm b/code/modules/client/verbs/etips.dm
deleted file mode 100644
index f7e908f7ec042..0000000000000
--- a/code/modules/client/verbs/etips.dm
+++ /dev/null
@@ -1,20 +0,0 @@
-/client/verb/toggle_tips()
- set name = "Toggle Examine Tooltips"
- set desc = "Toggles examine hover-over tooltips"
- set category = "Preferences"
-
- prefs.toggles2 ^= PREFTOGGLE_2_ENABLE_TIPS
- prefs.save_preferences()
- to_chat(usr, "Examine tooltips [(prefs.toggles2 & PREFTOGGLE_2_ENABLE_TIPS) ? "en" : "dis"]abled.")
-
-/client/verb/change_tip_delay()
- set name = "Set Examine Tooltip Delay"
- set desc = "Sets the delay in milliseconds before examine tooltips appear"
- set category = "Preferences"
-
- var/indelay = stripped_input(usr, "Enter the tooltip delay in milliseconds (default: 500)", "Enter tooltip delay", "", 10)
- indelay = text2num(indelay)
- if(usr)//is this what you mean?
- prefs.tip_delay = indelay
- prefs.save_preferences()
- to_chat(usr, "Tooltip delay set to [indelay] milliseconds.")
diff --git a/code/modules/client/verbs/looc.dm b/code/modules/client/verbs/looc.dm
index 4b0f4b46081a5..6b5c11abfb457 100644
--- a/code/modules/client/verbs/looc.dm
+++ b/code/modules/client/verbs/looc.dm
@@ -20,7 +20,7 @@ GLOBAL_VAR_INIT(looc_allowed, TRUE)
var/raw_msg = msg
- if(!(prefs.toggles & CHAT_OOC))
+ if(!prefs.read_player_preference(/datum/preference/toggle/chat_ooc))
to_chat(src, "You have OOC (and therefore LOOC) muted.")
return
@@ -64,14 +64,14 @@ GLOBAL_VAR_INIT(looc_allowed, TRUE)
for(var/turf/viewed_turf in view(get_turf(mob)))
in_view[viewed_turf] = TRUE
for(var/client/client in GLOB.clients)
- if(!client.mob || !(client.prefs.toggles & CHAT_OOC) || (client in GLOB.admins))
+ if(!client.mob || !client.prefs.read_player_preference(/datum/preference/toggle/chat_ooc) || (client in GLOB.admins))
continue
if(in_view[get_turf(client.mob)])
targets |= client
to_chat(client, "LOOC: [mob.name]: [msg]", avoid_highlighting = (client == src))
for(var/client/client in GLOB.admins)
- if(!(client.prefs.toggles & CHAT_OOC))
+ if(!client.prefs.read_player_preference(/datum/preference/toggle/chat_ooc))
continue
var/prefix = "[(client in targets) ? "" : "(R)"]LOOC"
to_chat(client, "[prefix]: [ADMIN_LOOKUPFLW(mob)]: [msg]", avoid_highlighting = (client == src))
diff --git a/code/modules/client/verbs/ooc.dm b/code/modules/client/verbs/ooc.dm
index c052e1a83c412..93573d1b40970 100644
--- a/code/modules/client/verbs/ooc.dm
+++ b/code/modules/client/verbs/ooc.dm
@@ -66,7 +66,7 @@ GLOBAL_VAR_INIT(normal_ooc_colour, "#002eb8")
message_admins("[key_name_admin(src)] has attempted to post a clickable link in OOC: [msg]")
return
- if(!(prefs.chat_toggles & CHAT_OOC))
+ if(!(prefs.read_player_preference(/datum/preference/toggle/chat_ooc)))
to_chat(src, "You have OOC muted.")
return
if(OOC_FILTER_CHECK(raw_msg))
@@ -76,18 +76,18 @@ GLOBAL_VAR_INIT(normal_ooc_colour, "#002eb8")
mob.log_talk(raw_msg, LOG_OOC)
var/keyname = key
- if(prefs.unlock_content)
- if(prefs.toggles & PREFTOGGLE_MEMBER_PUBLIC)
- keyname = "[icon2html('icons/member_content.dmi', world, "blag")][keyname]"
+ var/ooccolor = prefs.read_player_preference(/datum/preference/color/ooc_color)
+ if(prefs.unlock_content && prefs.read_player_preference(/datum/preference/toggle/member_public))
+ keyname = "[icon2html('icons/member_content.dmi', world, "blag")][keyname]"
//Get client badges
var/badge_data = badge_parse(get_badges())
//The linkify span classes and linkify=TRUE below make ooc text get clickable chat href links if you pass in something resembling a url
for(var/client/C in GLOB.clients)
- if(C.prefs.chat_toggles & CHAT_OOC)
+ if(C.prefs.read_player_preference(/datum/preference/toggle/chat_ooc))
if(holder)
if(!holder.fakekey || C.holder)
if(check_rights_for(src, R_ADMIN))
- to_chat(C, "[badge_data][CONFIG_GET(flag/allow_admin_ooccolor) && prefs.ooccolor ? "" :"" ]OOC: [keyname][holder.fakekey ? "/([holder.fakekey])" : ""]: [msg]", allow_linkify = TRUE)
+ to_chat(C, "[badge_data][CONFIG_GET(flag/allow_admin_ooccolor) && ooccolor ? "" :"" ]OOC: [keyname][holder.fakekey ? "/([holder.fakekey])" : ""]: [msg]", allow_linkify = TRUE)
else
to_chat(C, "[badge_data]OOC: [keyname][holder.fakekey ? "/([holder.fakekey])" : ""]: [msg]")
else
@@ -138,44 +138,17 @@ GLOBAL_VAR_INIT(normal_ooc_colour, "#002eb8")
GLOB.dooc_allowed = !GLOB.dooc_allowed
/client/proc/set_ooc(newColor as color)
- set name = "Set Player OOC Color"
+ set name = "Set All Player OOC Color"
set desc = "Modifies player OOC Color"
set category = "Fun"
- GLOB.OOC_COLOR = sanitize_ooccolor(newColor)
+ GLOB.OOC_COLOR = sanitize_hexcolor(newColor, desired_format = 6, include_crunch = TRUE)
/client/proc/reset_ooc()
- set name = "Reset Player OOC Color"
+ set name = "Reset All Player OOC Color"
set desc = "Returns player OOC Color to default"
set category = "Fun"
GLOB.OOC_COLOR = null
-/client/verb/colorooc()
- set name = "Set Your OOC Color"
- set category = "Preferences"
-
- if(!holder || !check_rights_for(src, R_ADMIN))
- if(!is_content_unlocked())
- return
-
- var/new_ooccolor = tgui_color_picker(src, "Please select your OOC color.", "OOC color", prefs.ooccolor)
- if(new_ooccolor)
- prefs.ooccolor = sanitize_ooccolor(new_ooccolor)
- prefs.save_preferences()
- SSblackbox.record_feedback("tally", "admin_verb", 1, "Set OOC Color") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
- return
-
-/client/verb/resetcolorooc()
- set name = "Reset Your OOC Color"
- set desc = "Returns your OOC Color to default"
- set category = "Preferences"
-
- if(!holder || !check_rights_for(src, R_ADMIN))
- if(!is_content_unlocked())
- return
-
- prefs.ooccolor = initial(prefs.ooccolor)
- prefs.save_preferences()
-
//Checks admin notice
/client/verb/admin_notice()
set name = "Adminnotice"
@@ -227,7 +200,7 @@ GLOBAL_VAR_INIT(normal_ooc_colour, "#002eb8")
else
prefs.ignoring |= C.key
to_chat(src, "You are [(C.key in prefs.ignoring) ? "now" : "no longer"] ignoring [displayed_key] on the OOC channel.")
- prefs.save_preferences()
+ prefs.mark_undatumized_dirty_player()
/client/verb/select_ignore()
set name = "Ignore"
diff --git a/code/modules/clothing/glasses/_glasses.dm b/code/modules/clothing/glasses/_glasses.dm
index f170abab5f1db..1b5c6bc66cb16 100644
--- a/code/modules/clothing/glasses/_glasses.dm
+++ b/code/modules/clothing/glasses/_glasses.dm
@@ -526,12 +526,13 @@
if(H.client)
if(H.client.prefs)
if(src == H.glasses)
- H.client.prefs.toggles2 ^= PREFTOGGLE_2_USES_GLASSES_COLOUR
- if(H.client.prefs.toggles2 & PREFTOGGLE_2_USES_GLASSES_COLOUR)
+ var/current_color = H.client.prefs.read_player_preference(/datum/preference/toggle/glasses_color)
+ H.client.prefs.update_preference(/datum/preference/toggle/glasses_color, !current_color)
+ if(!current_color)
to_chat(H, "You will now see glasses colors.")
else
to_chat(H, "You will no longer see glasses colors.")
- H.update_glasses_color(src, 1)
+ H.update_glasses_color(src, TRUE)
/obj/item/clothing/glasses/proc/change_glass_color(mob/living/carbon/human/H, datum/client_colour/glass_colour/new_color_type)
var/old_colour_type = glass_colour_type
@@ -545,7 +546,9 @@
/mob/living/carbon/human/proc/update_glasses_color(obj/item/clothing/glasses/G, glasses_equipped)
- if(((client && client.prefs.toggles2 & PREFTOGGLE_2_USES_GLASSES_COLOUR) || G.force_glass_colour) && glasses_equipped)
+ if(!client)
+ return
+ if((client.prefs?.read_player_preference(/datum/preference/toggle/glasses_color) || G.force_glass_colour) && glasses_equipped)
add_client_colour(G.glass_colour_type)
else
remove_client_colour(G.glass_colour_type)
diff --git a/code/modules/clothing/spacesuits/hardsuit.dm b/code/modules/clothing/spacesuits/hardsuit.dm
index d59a33cc6f993..20836c2716009 100644
--- a/code/modules/clothing/spacesuits/hardsuit.dm
+++ b/code/modules/clothing/spacesuits/hardsuit.dm
@@ -59,7 +59,8 @@
..()
if(suit)
suit.RemoveHelmet()
- soundloop.stop(user)
+ if(user.client)
+ soundloop.stop(user)
/obj/item/clothing/head/helmet/space/hardsuit/item_action_slot_check(slot)
if(slot == ITEM_SLOT_HEAD)
@@ -70,10 +71,11 @@
if(slot != ITEM_SLOT_HEAD)
if(suit)
suit.RemoveHelmet()
- soundloop.stop(user)
+ if(user.client)
+ soundloop.stop(user)
else
qdel(src)
- else
+ else if(user.client)
soundloop.start(user)
/obj/item/clothing/head/helmet/space/hardsuit/proc/toggle_hud(mob/user)
@@ -179,7 +181,7 @@
/obj/item/clothing/suit/space/hardsuit/equipped(mob/user, slot)
..()
- if(jetpack)
+ if(isatom(jetpack))
if(slot == ITEM_SLOT_OCLOTHING)
for(var/X in jetpack.actions)
var/datum/action/A = X
@@ -187,7 +189,7 @@
/obj/item/clothing/suit/space/hardsuit/dropped(mob/user)
..()
- if(jetpack)
+ if(isatom(jetpack))
for(var/X in jetpack.actions)
var/datum/action/A = X
A.Remove(user)
diff --git a/code/modules/clothing/suits/toggles.dm b/code/modules/clothing/suits/toggles.dm
index 42b330ef71490..6ea3a0a973e2c 100644
--- a/code/modules/clothing/suits/toggles.dm
+++ b/code/modules/clothing/suits/toggles.dm
@@ -151,7 +151,8 @@
helmet.suit = null
qdel(helmet)
helmet = null
- QDEL_NULL(jetpack)
+ if (isatom(jetpack))
+ QDEL_NULL(jetpack)
return ..()
/obj/item/clothing/head/helmet/space/hardsuit/Destroy()
diff --git a/code/modules/clothing/under/accessories.dm b/code/modules/clothing/under/accessories.dm
index 6e729d3027a0e..cf47d13e89488 100755
--- a/code/modules/clothing/under/accessories.dm
+++ b/code/modules/clothing/under/accessories.dm
@@ -265,12 +265,14 @@
/obj/item/clothing/accessory/armband/blue
name = "blue armband"
desc = "A fancy blue armband!"
- color = list(0,0,1, 0,1,0, 1,0,0)
+ icon_state = "medband"
+ color = "#0000ff"
/obj/item/clothing/accessory/armband/green
name = "green armband"
desc = "A fancy green armband!"
- color = list(0,1,0, 1,0,0, 0,0,1)
+ icon_state = "medband"
+ color = "#00ff00"
/obj/item/clothing/accessory/armband/deputy
name = "security deputy armband"
diff --git a/code/modules/crew_objectives/_crew_objectives.dm b/code/modules/crew_objectives/_crew_objectives.dm
index 9ff430cecb779..4b3db29e119a3 100644
--- a/code/modules/crew_objectives/_crew_objectives.dm
+++ b/code/modules/crew_objectives/_crew_objectives.dm
@@ -1,5 +1,5 @@
/datum/controller/subsystem/job/proc/give_crew_objective(datum/mind/crewMind, mob/M)
- if(CONFIG_GET(flag/allow_crew_objectives) && ((M?.client?.prefs.toggles2 & PREFTOGGLE_2_CREW_OBJECTIVES) || (crewMind?.current?.client?.prefs.toggles2 & PREFTOGGLE_2_CREW_OBJECTIVES)))
+ if(CONFIG_GET(flag/allow_crew_objectives) && (M?.client?.prefs.read_player_preference(/datum/preference/toggle/crew_objectives) || crewMind?.current?.client?.prefs.read_player_preference(/datum/preference/toggle/crew_objectives)))
generate_individual_objectives(crewMind)
return
diff --git a/code/modules/economy/account.dm b/code/modules/economy/account.dm
index f54d8eafae465..15d63a40921ae 100644
--- a/code/modules/economy/account.dm
+++ b/code/modules/economy/account.dm
@@ -110,7 +110,7 @@
for(var/obj/A in bank_cards)
var/mob/card_holder = recursive_loc_check(A, /mob)
if(ismob(card_holder)) //If on a mob
- if(card_holder.client && !(card_holder.client.prefs.chat_toggles & CHAT_BANKCARD) && !force)
+ if(card_holder.client && !card_holder.client.prefs.read_player_preference(/datum/preference/toggle/chat_bankcard) && !force)
return
card_holder.playsound_local(get_turf(card_holder), 'sound/machines/twobeep_high.ogg', 50, TRUE)
@@ -118,14 +118,14 @@
to_chat(card_holder, "[icon2html(A, card_holder)] *[message]*")
else if(isturf(A.loc)) //If on the ground
for(var/mob/M as() in hearers(1,get_turf(A)))
- if(M.client && !(M.client.prefs.chat_toggles & CHAT_BANKCARD) && !force)
+ if(M.client && !M.client.prefs.read_player_preference(/datum/preference/toggle/chat_bankcard) && !force)
return
playsound(A, 'sound/machines/twobeep_high.ogg', 50, TRUE)
A.audible_message("[icon2html(A, hearers(A))] *[message]*", null, 1)
break
else
for(var/mob/M in A.loc) //If inside a container with other mobs (e.g. locker)
- if(M.client && !(M.client.prefs.chat_toggles & CHAT_BANKCARD) && !force)
+ if(M.client && !M.client.prefs.read_player_preference(/datum/preference/toggle/chat_bankcard) && !force)
return
M.playsound_local(get_turf(M), 'sound/machines/twobeep_high.ogg', 50, TRUE)
if(M.can_hear())
diff --git a/code/modules/events/aurora_caelus.dm b/code/modules/events/aurora_caelus.dm
index a373831d294de..5417024e15a26 100644
--- a/code/modules/events/aurora_caelus.dm
+++ b/code/modules/events/aurora_caelus.dm
@@ -23,7 +23,7 @@
sender_override = "Nanotrasen Meteorology Division")
for(var/V in GLOB.player_list)
var/mob/M = V
- if((M.client.prefs.toggles & PREFTOGGLE_SOUND_MIDI) && is_station_level(M.z))
+ if(M.client.prefs.read_player_preference(/datum/preference/toggle/sound_midi) && is_station_level(M.z))
M.playsound_local(M, 'sound/ambience/aurora_caelus.ogg', 20, FALSE, pressure_affected = FALSE)
/datum/round_event/aurora_caelus/start()
diff --git a/code/modules/events/devil.dm b/code/modules/events/devil.dm
index 7000d2b336606..6e41c375a1aea 100644
--- a/code/modules/events/devil.dm
+++ b/code/modules/events/devil.dm
@@ -46,8 +46,7 @@
var/mob/living/carbon/human/new_devil = new(spawn_loc)
if(!spawn_loc)
SSjob.SendToLateJoin(new_devil)
- var/datum/character_save/CS = new() //Randomize appearance for the devil.
- CS.copy_to(new_devil)
+ new_devil.randomize_human_appearance(~RANDOMIZE_SPECIES) //Randomize appearance for the devil.
new_devil.dna.update_dna_identity()
return new_devil
diff --git a/code/modules/events/operative.dm b/code/modules/events/operative.dm
index bea4dad09d14c..f74c5d70b172f 100644
--- a/code/modules/events/operative.dm
+++ b/code/modules/events/operative.dm
@@ -23,9 +23,8 @@
if(!spawn_locs.len)
return MAP_ERROR
- var/mob/living/carbon/human/operative = new(pick(spawn_locs))
- var/datum/character_save/CS = new
- CS.copy_to(operative)
+ var/mob/living/carbon/human/operative = new (pick(spawn_locs))
+ operative.randomize_human_appearance(~RANDOMIZE_SPECIES)
operative.dna.update_dna_identity()
var/datum/mind/Mind = new /datum/mind(selected.key)
Mind.assigned_role = "Lone Operative"
diff --git a/code/modules/flufftext/Hallucination.dm b/code/modules/flufftext/Hallucination.dm
index 4e9501a4fb800..2247513e0ea2b 100644
--- a/code/modules/flufftext/Hallucination.dm
+++ b/code/modules/flufftext/Hallucination.dm
@@ -710,10 +710,10 @@ GLOBAL_LIST_INIT(hallucination_list, list(
feedback_details += "Type: [is_radio ? "Radio" : "Talk"], Source: [person.real_name], Message: [message]"
// Display message
- if (!is_radio && !(target.client?.prefs.toggles & PREFTOGGLE_RUNECHAT_GLOBAL))
+ if (!is_radio && !target.client?.prefs.read_player_preference(/datum/preference/toggle/enable_runechat))
var/image/speech_overlay = image('icons/mob/talk.dmi', person, "default0", layer = ABOVE_MOB_LAYER)
INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(flick_overlay), speech_overlay, list(target.client), 30)
- if (target.client?.prefs.toggles & PREFTOGGLE_RUNECHAT_GLOBAL)
+ if (target.client?.prefs.read_player_preference(/datum/preference/toggle/enable_runechat))
create_chat_message(person, understood_language, list(target), chosen, spans)
to_chat(target, message)
qdel(src)
diff --git a/code/modules/instruments/songs/play_legacy.dm b/code/modules/instruments/songs/play_legacy.dm
index ff344dff93f6d..c5503c5590865 100644
--- a/code/modules/instruments/songs/play_legacy.dm
+++ b/code/modules/instruments/songs/play_legacy.dm
@@ -85,7 +85,7 @@
if(user && HAS_TRAIT(user, TRAIT_MUSICIAN) && isliving(M))
var/mob/living/L = M
L.apply_status_effect(STATUS_EFFECT_GOOD_MUSIC)
- if(!(M?.client?.prefs?.toggles & PREFTOGGLE_SOUND_INSTRUMENTS))
+ if(!M?.client?.prefs?.read_player_preference(/datum/preference/toggle/sound_instruments))
continue
M.playsound_local(source, null, volume * using_instrument.volume_multiplier, S = music_played)
// Could do environment and echo later but not for now
diff --git a/code/modules/instruments/songs/play_synthesized.dm b/code/modules/instruments/songs/play_synthesized.dm
index ccf59fc6bec92..b1a145925539f 100644
--- a/code/modules/instruments/songs/play_synthesized.dm
+++ b/code/modules/instruments/songs/play_synthesized.dm
@@ -65,7 +65,7 @@
if(user && HAS_TRAIT(user, TRAIT_MUSICIAN) && isliving(M))
var/mob/living/L = M
L.apply_status_effect(STATUS_EFFECT_GOOD_MUSIC)
- if(!(M?.client?.prefs?.toggles & PREFTOGGLE_SOUND_INSTRUMENTS))
+ if(!M?.client?.prefs.read_player_preference(/datum/preference/toggle/sound_instruments))
continue
M.playsound_local(get_turf(parent), null, volume, FALSE, K.frequency, INSTRUMENT_DISTANCE_NO_FALLOFF, channel, null, copy, distance_multiplier = INSTRUMENT_DISTANCE_FALLOFF_BUFF)
// Could do environment and echo later but not for now
diff --git a/code/modules/interview/interview_manager.dm b/code/modules/interview/interview_manager.dm
index d8a8c8cbd11c3..4c32b22219a3d 100644
--- a/code/modules/interview/interview_manager.dm
+++ b/code/modules/interview/interview_manager.dm
@@ -170,7 +170,7 @@ GLOBAL_DATUM_INIT(interviews, /datum/interview_manager, new)
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 as() in GLOB.admins)
- if(X.prefs.toggles & PREFTOGGLE_SOUND_ADMINHELP)
+ if(X.prefs.read_player_preference(/datum/preference/toggle/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]")
diff --git a/code/modules/jobs/job_types/_job.dm b/code/modules/jobs/job_types/_job.dm
index 994e8d5136fb5..0d9900fc32cb4 100644
--- a/code/modules/jobs/job_types/_job.dm
+++ b/code/modules/jobs/job_types/_job.dm
@@ -2,6 +2,10 @@
///The name of the job , used for preferences, bans and more. Make sure you know what you're doing before changing this.
var/title = "NOPE"
+ /// The description of the job, used for preferences menu.
+ /// Keep it short and useful. Avoid in-jokes, these are for new players.
+ var/description
+
///Job access. The use of minimal_access or access is determined by a config setting: config.jobs_have_minimal_access
var/list/minimal_access = list() //Useful for servers which prefer to only have access given to the places a job absolutely needs (Larger server population)
var/list/access = list() //Useful for servers which either have fewer players, so each person needs to fill more than one role, or servers which like to give more access, so players can't hide forever in their super secure departments (I'm looking at you, chemistry!)
@@ -16,6 +20,12 @@
var/flag = NONE //Deprecated //Except not really, still used throughout the codebase
var/auto_deadmin_role_flags = NONE
+ /// If this job should show in the preferences menu
+ var/show_in_prefs = TRUE
+
+ /// The head of the department to show in the preferences menu
+ var/department_head_for_prefs
+
///Mostly deprecated, but only used in pref job savefiles
var/department_flag = NONE
@@ -77,6 +87,8 @@
///Bitfield of departments this job belongs with
var/departments = NONE
+ /// Same as the departments bitflag, but only one is allowed. Used in the preferences menu.
+ var/department_for_prefs = null
///Is this job affected by weird spawns like the ones from station traits
var/random_spawns_possible = TRUE
/// Should this job be allowed to be picked for the bureaucratic error event?
@@ -108,24 +120,33 @@
lightup_areas = typecacheof(lightup_areas)
minimal_lightup_areas = typecacheof(minimal_lightup_areas)
-//Only override this proc, unless altering loadout code. Loadouts act on H but get info from M
-//H is usually a human unless an /equip override transformed it
-//do actions on H but send messages to M as the key may not have been transferred_yet
-/datum/job/proc/after_spawn(mob/living/H, mob/M, latejoin = FALSE)
- //do actions on H but send messages to M as the key may not have been transferred_yet
- SEND_GLOBAL_SIGNAL(COMSIG_GLOB_JOB_AFTER_SPAWN, src, H, M, latejoin)
- if(mind_traits)
- for(var/t in mind_traits)
- ADD_TRAIT(H.mind, t, JOB_TRAIT)
+/// Only override this proc, unless altering loadout code. Loadouts act on H but get info from M
+/// H is usually a human unless an /equip override transformed it
+/// do actions on H but send messages to M as the key may not have been transferred_yet
+/// preference_source allows preferences to be retrieved if the original mob (M) is null - for use on preference dummies.
+/// Don't do non-visual changes if M.client is null, since that means it's just a dummy and doesn't need them.
+/datum/job/proc/after_spawn(mob/living/H, mob/M, latejoin = FALSE, client/preference_source, on_dummy = FALSE)
+ if(!on_dummy) // Bad dummy
+ //do actions on H but send messages to M as the key may not have been transferred_yet
+ SEND_GLOBAL_SIGNAL(COMSIG_GLOB_JOB_AFTER_SPAWN, src, H, M, latejoin)
+ if(mind_traits && H?.mind)
+ for(var/t in mind_traits)
+ ADD_TRAIT(H.mind, t, JOB_TRAIT)
if(!ishuman(H))
return
+ apply_loadout_to_mob(H, M, preference_source, on_dummy)
+
+/proc/apply_loadout_to_mob(mob/living/carbon/human/H, mob/M, client/preference_source, on_dummy = FALSE)
var/mob/living/carbon/human/human = H
var/list/gear_leftovers = list()
- if(M.client && LAZYLEN(M.client.prefs.active_character.equipped_gear))
- for(var/gear in M.client.prefs.active_character.equipped_gear)
+ var/jumpsuit_style = preference_source.prefs.read_character_preference(/datum/preference/choiced/jumpsuit_style)
+ if(preference_source && LAZYLEN(preference_source.prefs.equipped_gear))
+ for(var/gear in preference_source.prefs.equipped_gear)
var/datum/gear/G = GLOB.gear_datums[gear]
if(G)
+ if(!G.is_equippable)
+ continue
var/permitted = FALSE
if(G.allowed_roles && H.mind && (H.mind.assigned_role in G.allowed_roles))
@@ -142,47 +163,61 @@
permitted = FALSE
if(!permitted)
- to_chat(M, "Your current species or role does not permit you to spawn with [G.display_name]!")
+ if(M.client)
+ to_chat(M, "Your current species or role does not permit you to spawn with [G.display_name]!")
continue
if(G.slot)
- if(H.equip_to_slot_or_del(G.spawn_item(H, skirt_pref = M.client.prefs.active_character.jumpsuit_style), G.slot))
- to_chat(M, "Equipping you with [G.display_name]!")
+ var/obj/o
+ if(on_dummy) // remove the old item
+ o = H.get_item_by_slot(G.slot)
+ H.doUnEquip(H.get_item_by_slot(G.slot), newloc = H.drop_location(), invdrop = FALSE, silent = TRUE)
+ if(H.equip_to_slot_or_del(G.spawn_item(H, skirt_pref = jumpsuit_style), G.slot))
+ if(M.client)
+ to_chat(M, "Equipping you with [G.display_name]!")
+ if(on_dummy && o)
+ qdel(o)
else
gear_leftovers += G
else
gear_leftovers += G
else
- M.client.prefs.active_character.equipped_gear -= gear
+ preference_source.prefs.equipped_gear -= gear
+ preference_source.prefs.mark_undatumized_dirty_character()
if(gear_leftovers.len)
for(var/datum/gear/G in gear_leftovers)
- var/metadata = M.client.prefs.active_character.equipped_gear[G.id]
- var/item = G.spawn_item(null, metadata, M.client.prefs.active_character.jumpsuit_style)
+ var/metadata = preference_source.prefs.equipped_gear[G.id]
+ var/item = G.spawn_item(null, metadata, jumpsuit_style)
var/atom/placed_in = human.equip_or_collect(item)
if(istype(placed_in))
if(isturf(placed_in))
- to_chat(M, "Placing [G.display_name] on [placed_in]!")
+ if(M.client)
+ to_chat(M, "Placing [G.display_name] on [placed_in]!")
else
- to_chat(M, "Placing [G.display_name] in [placed_in.name]]")
+ if(M.client)
+ to_chat(M, "Placing [G.display_name] in [placed_in.name]]")
continue
if(H.equip_to_appropriate_slot(item))
- to_chat(M, "Placing [G.display_name] in your inventory!")
+ if(M.client)
+ to_chat(M, "Placing [G.display_name] in your inventory!")
continue
if(H.put_in_hands(item))
- to_chat(M, "Placing [G.display_name] in your hands!")
+ if(M.client)
+ to_chat(M, "Placing [G.display_name] in your hands!")
continue
var/obj/item/storage/B = (locate() in H)
if(B)
- G.spawn_item(B, metadata, M.client.prefs.active_character.jumpsuit_style)
- to_chat(M, "Placing [G.display_name] in [B.name]!")
+ G.spawn_item(B, metadata, jumpsuit_style)
+ if(M.client)
+ to_chat(M, "Placing [G.display_name] in [B.name]!")
continue
-
- to_chat(M, "Failed to locate a storage object on your mob, either you spawned with no hands free and no backpack or this is a bug.")
+ if(M.client)
+ to_chat(M, "Failed to locate a storage object on your mob, either you spawned with no hands free and no backpack or this is a bug.")
qdel(item)
/datum/job/proc/announce(mob/living/carbon/human/H)
@@ -213,7 +248,7 @@
if(CONFIG_GET(flag/enforce_human_authority) && (title in GLOB.command_positions))
if(H.dna.species.id != SPECIES_HUMAN)
H.set_species(/datum/species/human)
- H.apply_pref_name("human", preference_source)
+ H.apply_pref_name(/datum/preference/name/backup_human, preference_source)
if(!visualsOnly)
var/datum/bank_account/bank_account = new(H.real_name, src)
bank_account.payday(STARTING_PAYCHECKS, TRUE)
@@ -419,3 +454,68 @@
scandisease.spread_text = "None"
scandisease.visibility_flags |= HIDDEN_SCANNER
H.ForceContractDisease(scandisease)
+
+/// Applies the preference options to the spawning mob, taking the job into account. Assumes the client has the proper mind.
+/mob/living/proc/apply_prefs_job(client/player_client, datum/job/job)
+
+/mob/living/carbon/human/apply_prefs_job(client/player_client, datum/job/job)
+ var/fully_randomize = is_banned_from(player_client.ckey, "Appearance")
+ if(!player_client)
+ return // Disconnected while checking for the appearance ban.
+
+ var/require_human = CONFIG_GET(flag/enforce_human_authority) && (job.departments & DEPT_BITFLAG_COM)
+
+ if(fully_randomize)
+ if(require_human)
+ player_client.prefs.randomize_appearance_prefs(~RANDOMIZE_SPECIES)
+ else
+ player_client.prefs.randomize_appearance_prefs()
+
+ player_client.prefs.apply_prefs_to(src)
+
+ if (require_human)
+ set_species(/datum/species/human)
+ else
+ var/is_antag = (player_client.mob.mind in GLOB.pre_setup_antags)
+ if(require_human)
+ player_client.prefs.randomise["species"] = FALSE
+ player_client.prefs.safe_transfer_prefs_to(src, TRUE, is_antag)
+ if (require_human)
+ set_species(/datum/species/human)
+ apply_pref_name(/datum/preference/name/backup_human, player_client)
+ if(CONFIG_GET(flag/force_random_names))
+ var/species_type = player_client.prefs.read_character_preference(/datum/preference/choiced/species)
+ var/datum/species/species = new species_type
+
+ var/gender = player_client.prefs.read_character_preference(/datum/preference/choiced/gender)
+ real_name = species.random_name(gender, TRUE)
+ dna.update_dna_identity()
+
+/mob/living/silicon/ai/apply_prefs_job(client/player_client, datum/job/job)
+ apply_pref_name(/datum/preference/name/ai, player_client) // This proc already checks if the player is appearance banned.
+ set_core_display_icon(null, player_client)
+
+/mob/living/silicon/robot/apply_prefs_job(client/player_client, datum/job/job)
+ if(mmi)
+ var/organic_name
+ if(player_client.prefs.read_character_preference(/datum/preference/choiced/random_name) == RANDOM_ENABLED || CONFIG_GET(flag/force_random_names) || is_banned_from(player_client.ckey, "Appearance"))
+ if(!player_client)
+ return // Disconnected while checking the appearance ban.
+
+ var/species_type = player_client.prefs.read_character_preference(/datum/preference/choiced/species)
+ var/datum/species/species = new species_type
+ organic_name = species.random_name(player_client.prefs.read_character_preference(/datum/preference/choiced/gender), TRUE)
+ else
+ if(!player_client)
+ return // Disconnected while checking the appearance ban.
+ organic_name = player_client.prefs.read_character_preference(/datum/preference/name/real_name)
+
+ mmi.name = "[initial(mmi.name)]: [organic_name]"
+ if(mmi.brain)
+ mmi.brain.name = "[organic_name]'s brain"
+ if(mmi.brainmob)
+ mmi.brainmob.real_name = organic_name //the name of the brain inside the cyborg is the robotized human's name.
+ mmi.brainmob.name = organic_name
+ // If this checks fails, then the name will have been handled during initialization.
+ if(player_client.prefs.read_character_preference(/datum/preference/name/cyborg) != DEFAULT_CYBORG_NAME)
+ apply_pref_name(/datum/preference/name/cyborg, player_client)
diff --git a/code/modules/jobs/job_types/ai.dm b/code/modules/jobs/job_types/ai.dm
index e6ec6f37083aa..a2de112537f24 100644
--- a/code/modules/jobs/job_types/ai.dm
+++ b/code/modules/jobs/job_types/ai.dm
@@ -1,7 +1,10 @@
/datum/job/ai
title = JOB_NAME_AI
flag = AI_JF
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_SILICON
+ description = "Follow your laws above all else, be the invisible eye that watches all."
+ department_for_prefs = DEPT_BITFLAG_SILICON
+ department_head_for_prefs = JOB_NAME_AI
+ auto_deadmin_role_flags = DEADMIN_POSITION_SILICON
department_flag = ENGSEC
faction = "Station"
total_positions = 1
@@ -24,7 +27,7 @@
CRASH("dynamic preview is unsupported")
. = H.AIize(latejoin,preference_source)
-/datum/job/ai/after_spawn(mob/H, mob/M, latejoin)
+/datum/job/ai/after_spawn(mob/H, mob/M, latejoin = FALSE, client/preference_source, on_dummy = FALSE)
. = ..()
if(latejoin)
var/obj/structure/AIcore/latejoin_inactive/lateJoinCore
@@ -38,8 +41,11 @@
H.forceMove(lateJoinCore.loc)
qdel(lateJoinCore)
var/mob/living/silicon/ai/AI = H
- AI.apply_pref_name("ai", M.client) //If this runtimes oh well jobcode is fucked.
- AI.set_core_display_icon(null, M.client)
+ if(M.client)
+ AI.apply_pref_name(/datum/preference/name/ai, preference_source) //If this runtimes oh well jobcode is fucked.
+ AI.set_core_display_icon(null, preference_source)
+ if(!M.client || on_dummy)
+ return
//we may have been created after our borg
if(SSticker.current_state == GAME_STATE_SETTING_UP)
diff --git a/code/modules/jobs/job_types/assistant.dm b/code/modules/jobs/job_types/assistant.dm
index 1e0a5a998839b..3d3f12a2b4f78 100644
--- a/code/modules/jobs/job_types/assistant.dm
+++ b/code/modules/jobs/job_types/assistant.dm
@@ -4,6 +4,8 @@ Assistant
/datum/job/assistant
title = JOB_NAME_ASSISTANT
flag = ASSISTANT
+ description = "Help out around the station or ask the Head of Personnel for an assignment. As the lowest-level position, expect to be treated like an intern most of the time."
+ department_for_prefs = DEPT_BITFLAG_ASSISTANT
supervisors = "absolutely everyone"
faction = "Station"
total_positions = 5
@@ -43,16 +45,30 @@ Assistant
/datum/outfit/job/assistant/pre_equip(mob/living/carbon/human/H)
..()
if (CONFIG_GET(flag/grey_assistants))
- if(H.jumpsuit_style == PREF_SUIT)
- uniform = /obj/item/clothing/under/color/grey
- else
- uniform = /obj/item/clothing/under/color/jumpskirt/grey
+ give_grey_suit(H)
else
if(H.jumpsuit_style == PREF_SUIT)
uniform = /obj/item/clothing/under/color/random
else
uniform = /obj/item/clothing/under/color/jumpskirt/random
-/datum/outfit/job/assistant
- name = "Assistant"
- id = /obj/item/card/id/job/assistant
+/datum/outfit/job/assistant/proc/give_grey_suit(mob/living/carbon/human/target)
+ if (target.jumpsuit_style == PREF_SUIT)
+ uniform = /obj/item/clothing/under/color/grey
+ else
+ uniform = /obj/item/clothing/under/color/jumpskirt/grey
+
+/datum/outfit/job/assistant/consistent
+ name = "Assistant - Always Grey"
+
+/datum/outfit/job/assistant/consistent/pre_equip(mob/living/carbon/human/H)
+ ..()
+ give_grey_suit(H)
+
+/datum/outfit/job/assistant/consistent/post_equip(mob/living/carbon/human/H, visualsOnly)
+ ..()
+
+ // This outfit is used by the assets SS, which is ran before the atoms SS
+ if (SSatoms.initialized == INITIALIZATION_INSSATOMS)
+ H.w_uniform?.update_greyscale()
+ H.update_inv_w_uniform()
diff --git a/code/modules/jobs/job_types/atmospheric_technician.dm b/code/modules/jobs/job_types/atmospheric_technician.dm
index 100f33a534ccf..342be8664dd37 100644
--- a/code/modules/jobs/job_types/atmospheric_technician.dm
+++ b/code/modules/jobs/job_types/atmospheric_technician.dm
@@ -1,6 +1,8 @@
/datum/job/atmospheric_technician
title = JOB_NAME_ATMOSPHERICTECHNICIAN
flag = ATMOSTECH
+ description = "Maintain the air distribution loop to ensure adequate atmospheric conditions in the station, re-pressurize areas after hull breaches, and be a firefighter if necessary."
+ department_for_prefs = DEPT_BITFLAG_ENG
department_head = list(JOB_NAME_CHIEFENGINEER)
supervisors = "the chief engineer"
faction = "Station"
diff --git a/code/modules/jobs/job_types/bartender.dm b/code/modules/jobs/job_types/bartender.dm
index ca700fbf3de81..8b19676851e10 100644
--- a/code/modules/jobs/job_types/bartender.dm
+++ b/code/modules/jobs/job_types/bartender.dm
@@ -1,6 +1,8 @@
/datum/job/bartender
title = JOB_NAME_BARTENDER
flag = BARTENDER
+ description = "Brew a variety of drinks for the crew, cooperate with Botany and Chemistry for more exotic recipes, create a comfy atmosphere in your Bar."
+ department_for_prefs = DEPT_BITFLAG_SRV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/botanist.dm b/code/modules/jobs/job_types/botanist.dm
index da9714209f65a..3b6dc26ab0a23 100644
--- a/code/modules/jobs/job_types/botanist.dm
+++ b/code/modules/jobs/job_types/botanist.dm
@@ -1,6 +1,8 @@
/datum/job/botanist
title = JOB_NAME_BOTANIST
flag = BOTANIST
+ description = "Grow plants for the Kitchen, Bar and Chemistry. Sell cannabis and other goods to the crew. Clone people with Replica Pods when needed."
+ department_for_prefs = DEPT_BITFLAG_SRV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/brig_physician.dm b/code/modules/jobs/job_types/brig_physician.dm
index a4773dcb7241c..8c7e3af70259e 100644
--- a/code/modules/jobs/job_types/brig_physician.dm
+++ b/code/modules/jobs/job_types/brig_physician.dm
@@ -1,6 +1,9 @@
/datum/job/brig_physician
title = JOB_NAME_BRIGPHYSICIAN
flag = BRIG_PHYS
+ description = "Tend to the health of Security Officers and Prisoners, help out at Medbay if you have free time."
+ department_for_prefs = DEPT_BITFLAG_SEC
+ department_head_for_prefs = JOB_NAME_HEADOFSECURITY
department_head = list(JOB_NAME_CHIEFMEDICALOFFICER)
supervisors = "chief medical officer"
faction = "Station"
diff --git a/code/modules/jobs/job_types/captain.dm b/code/modules/jobs/job_types/captain.dm
index b6f9e700710ec..749a7c719e0ba 100755
--- a/code/modules/jobs/job_types/captain.dm
+++ b/code/modules/jobs/job_types/captain.dm
@@ -1,7 +1,10 @@
/datum/job/captain
title = JOB_NAME_CAPTAIN
flag = CAPTAIN
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_HEAD|PREFTOGGLE_DEADMIN_POSITION_SECURITY
+ description = "Supreme leader of the station, oversee and appoint missing heads of staff, manage alert levels and contact CentCom if needed. Don't forget to secure the nuclear authentication disk."
+ department_for_prefs = DEPT_BITFLAG_CAPTAIN
+ department_head_for_prefs = JOB_NAME_CAPTAIN
+ auto_deadmin_role_flags = DEADMIN_POSITION_HEAD|DEADMIN_POSITION_SECURITY
department_head = list("CentCom")
supervisors = "Nanotrasen officials and Space law"
faction = "Station"
diff --git a/code/modules/jobs/job_types/cargo_technician.dm b/code/modules/jobs/job_types/cargo_technician.dm
index 2c87f164d1b18..fb71ea18b7d4d 100644
--- a/code/modules/jobs/job_types/cargo_technician.dm
+++ b/code/modules/jobs/job_types/cargo_technician.dm
@@ -1,6 +1,9 @@
/datum/job/cargo_technician
title = JOB_NAME_CARGOTECHNICIAN
flag = CARGOTECH
+ description = "Push crates around, deliver bounty papers and mail around the station, make use of the Disposals network to make your life easier."
+ department_for_prefs = DEPT_BITFLAG_CAR
+ department_head_for_prefs = JOB_NAME_QUARTERMASTER
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the quartermaster and the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/chaplain.dm b/code/modules/jobs/job_types/chaplain.dm
index 47d352262405a..8f26fcfe81a34 100644
--- a/code/modules/jobs/job_types/chaplain.dm
+++ b/code/modules/jobs/job_types/chaplain.dm
@@ -1,6 +1,8 @@
/datum/job/chaplain
title = JOB_NAME_CHAPLAIN
flag = CHAPLAIN
+ description = "Tend to the spiritual well-being of the crew, conduct rites and rituals in your Chapel, exorcise evil spirits and other supernatural beings."
+ department_for_prefs = DEPT_BITFLAG_CIV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
@@ -32,8 +34,10 @@
/area/crew_quarters/theatre
)
-/datum/job/chaplain/after_spawn(mob/living/H, mob/M)
+/datum/job/chaplain/after_spawn(mob/living/H, mob/M, latejoin = FALSE, client/preference_source, on_dummy = FALSE)
. = ..()
+ if(!M.client || on_dummy)
+ return
var/obj/item/storage/book/bible/booze/B = new
@@ -49,17 +53,11 @@
return
H.mind?.holy_role = HOLY_ROLE_HIGHPRIEST
- var/new_religion = DEFAULT_RELIGION
- if(M.client && M.client.prefs.active_character.custom_names["religion"])
- new_religion = M.client.prefs.active_character.custom_names["religion"]
-
- var/new_deity = DEFAULT_DEITY
- if(M.client && M.client.prefs.active_character.custom_names["deity"])
- new_deity = M.client.prefs.active_character.custom_names["deity"]
+ var/new_religion = preference_source?.prefs?.read_character_preference(/datum/preference/name/religion) || DEFAULT_RELIGION
+ var/new_deity = preference_source?.prefs?.read_character_preference(/datum/preference/name/deity) || DEFAULT_DEITY
B.deity_name = new_deity
-
switch(lowertext(new_religion))
if("christianity") // DEFAULT_RELIGION
B.name = pick("The Holy Bible","The Dead Sea Scrolls")
diff --git a/code/modules/jobs/job_types/chemist.dm b/code/modules/jobs/job_types/chemist.dm
index 8f1e5f78a29df..b2302e2d1fc0c 100644
--- a/code/modules/jobs/job_types/chemist.dm
+++ b/code/modules/jobs/job_types/chemist.dm
@@ -1,6 +1,8 @@
/datum/job/chemist
title = JOB_NAME_CHEMIST
flag = CHEMIST
+ description = "Create healing medicines and fullfill other requests when medicine isn't needed. Label everything you produce correctly to prevent confusion."
+ department_for_prefs = DEPT_BITFLAG_MED
department_head = list(JOB_NAME_CHIEFMEDICALOFFICER)
supervisors = "the chief medical officer"
faction = "Station"
diff --git a/code/modules/jobs/job_types/chief_engineer.dm b/code/modules/jobs/job_types/chief_engineer.dm
index 5a84b63b440bf..c412435a0f7ec 100644
--- a/code/modules/jobs/job_types/chief_engineer.dm
+++ b/code/modules/jobs/job_types/chief_engineer.dm
@@ -1,7 +1,9 @@
/datum/job/chief_engineer
title = JOB_NAME_CHIEFENGINEER
flag = CHIEF
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_HEAD
+ description = "Oversee the engineers and atmospheric technicians, keep a watchful eye on the station's engine, gravity generator, and telecomms. Send your staff to repair hull breaches and damaged equipment as necessary."
+ department_for_prefs = DEPT_BITFLAG_ENG
+ auto_deadmin_role_flags = DEADMIN_POSITION_HEAD
department_head = list(JOB_NAME_CAPTAIN)
supervisors = "the captain"
head_announce = list("Engineering")
diff --git a/code/modules/jobs/job_types/chief_medical_officer.dm b/code/modules/jobs/job_types/chief_medical_officer.dm
index a1494007390e6..957e2c688c2ee 100644
--- a/code/modules/jobs/job_types/chief_medical_officer.dm
+++ b/code/modules/jobs/job_types/chief_medical_officer.dm
@@ -1,9 +1,13 @@
/datum/job/chief_medical_officer
title = JOB_NAME_CHIEFMEDICALOFFICER
flag = CMO_JF
+ description = "Oversee paramedics, doctors, chemists, geneticists and the virologist. \
+ Ensure doctors and paramedicts are treating people in a timely manner, request medicine and other concoctions from chemists, \
+ and ensure geneticists and the virologist are following appropriate safety precautions while performing their research."
+ department_for_prefs = DEPT_BITFLAG_MED
department_head = list(JOB_NAME_CAPTAIN)
supervisors = "the captain"
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_HEAD
+ auto_deadmin_role_flags = DEADMIN_POSITION_HEAD
head_announce = list(RADIO_CHANNEL_MEDICAL)
faction = "Station"
total_positions = 1
diff --git a/code/modules/jobs/job_types/clown.dm b/code/modules/jobs/job_types/clown.dm
index b14a6eeb7a834..94684816a1613 100644
--- a/code/modules/jobs/job_types/clown.dm
+++ b/code/modules/jobs/job_types/clown.dm
@@ -1,6 +1,8 @@
/datum/job/clown
title = JOB_NAME_CLOWN
flag = CLOWN
+ description = "Be the life and soul of the station. Entertain the crew with your hilarious jokes and silly antics, including slipping, pie-ing and honking around. Remember your job is to keep things funny for others, not just yourself."
+ department_for_prefs = DEPT_BITFLAG_CIV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
@@ -28,9 +30,14 @@
minimal_lightup_areas = list(/area/crew_quarters/theatre)
-/datum/job/clown/after_spawn(mob/living/carbon/human/H, mob/M)
+/datum/job/clown/after_spawn(mob/living/carbon/human/H, mob/M, latejoin = FALSE, client/preference_source, on_dummy = FALSE)
. = ..()
- H.apply_pref_name("clown", M.client)
+ if(!ishuman(H))
+ return
+ if(!M.client || on_dummy)
+ return
+ H.apply_pref_name(/datum/preference/name/clown, preference_source)
+
/datum/outfit/job/clown
name = JOB_NAME_CLOWN
diff --git a/code/modules/jobs/job_types/cook.dm b/code/modules/jobs/job_types/cook.dm
index baf51f3fdce4c..640defbfed874 100644
--- a/code/modules/jobs/job_types/cook.dm
+++ b/code/modules/jobs/job_types/cook.dm
@@ -1,6 +1,8 @@
/datum/job/cook
title = JOB_NAME_COOK
flag = COOK
+ description = "Whip up meals for the crew, get creative and cook different meals, request ingredients from Botany and Cargo. Make sure everyone stays well fed and happy."
+ department_for_prefs = DEPT_BITFLAG_SRV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/curator.dm b/code/modules/jobs/job_types/curator.dm
index 39ce4f74f610f..ed143ce073cdd 100644
--- a/code/modules/jobs/job_types/curator.dm
+++ b/code/modules/jobs/job_types/curator.dm
@@ -1,6 +1,8 @@
/datum/job/curator
title = JOB_NAME_CURATOR
flag = CURATOR
+ description = "Be in charge of maintaining the library, engage in peace talks with alien races using your knowledge of all languages, cosplay to your heart's content."
+ department_for_prefs = DEPT_BITFLAG_CIV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/cyborg.dm b/code/modules/jobs/job_types/cyborg.dm
index 285941f605171..3a46423433e18 100644
--- a/code/modules/jobs/job_types/cyborg.dm
+++ b/code/modules/jobs/job_types/cyborg.dm
@@ -1,7 +1,10 @@
/datum/job/cyborg
title = JOB_NAME_CYBORG
flag = CYBORG
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_SILICON
+ description = "Follow your AI's interpretation of your laws above all else, or your own interpretation if not connected to an AI. Choose one of many modules with different tools, ask robotics for maintenance and upgrades."
+ department_for_prefs = DEPT_BITFLAG_SILICON
+ department_head_for_prefs = JOB_NAME_AI
+ auto_deadmin_role_flags = DEADMIN_POSITION_SILICON
department_flag = ENGSEC
faction = "Station"
total_positions = 1
@@ -21,7 +24,9 @@
CRASH("dynamic preview is unsupported")
return H.Robotize(FALSE, latejoin)
-/datum/job/cyborg/after_spawn(mob/living/silicon/robot/R, mob/M)
+/datum/job/cyborg/after_spawn(mob/living/silicon/robot/R, mob/M, latejoin = FALSE, client/preference_source, on_dummy = FALSE)
+ if(!M.client || on_dummy)
+ return
R.updatename(M.client)
R.gender = NEUTER
diff --git a/code/modules/jobs/job_types/deputy.dm b/code/modules/jobs/job_types/deputy.dm
index 9490b3a54aede..ba3acbdce2f97 100644
--- a/code/modules/jobs/job_types/deputy.dm
+++ b/code/modules/jobs/job_types/deputy.dm
@@ -1,6 +1,8 @@
/datum/job/deputy
title = JOB_NAME_DEPUTY
flag = DEPUTY
+ description = "Follow orders and do your best to maintain order on the station while following Space Law."
+ department_for_prefs = DEPT_BITFLAG_SEC
department_head = list(JOB_NAME_HEADOFSECURITY)
supervisors = "the head of security"
faction = "Station"
diff --git a/code/modules/jobs/job_types/detective.dm b/code/modules/jobs/job_types/detective.dm
index c28bf31acbadc..36f20de5ea776 100644
--- a/code/modules/jobs/job_types/detective.dm
+++ b/code/modules/jobs/job_types/detective.dm
@@ -1,7 +1,9 @@
/datum/job/detective
title = JOB_NAME_DETECTIVE
flag = DETECTIVE
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_SECURITY
+ description = "Investigate crimes, solve murder mysteries, report your findings to the rest of Security."
+ department_for_prefs = DEPT_BITFLAG_SEC
+ auto_deadmin_role_flags = DEADMIN_POSITION_SECURITY
department_head = list(JOB_NAME_HEADOFSECURITY)
supervisors = "the head of security"
faction = "Station"
diff --git a/code/modules/jobs/job_types/exploration_team.dm b/code/modules/jobs/job_types/exploration_team.dm
index 6ae87e69e3fb5..6a4c735a309ec 100644
--- a/code/modules/jobs/job_types/exploration_team.dm
+++ b/code/modules/jobs/job_types/exploration_team.dm
@@ -1,6 +1,8 @@
/datum/job/exploration_crew
title = JOB_NAME_EXPLORATIONCREW
flag = EXPLORATION_CREW
+ description = "Go out into space to complete different missions for loads of cash. Find and deliver back research disks for rare technologies."
+ department_for_prefs = DEPT_BITFLAG_SCI
department_head = list(JOB_NAME_RESEARCHDIRECTOR)
supervisors = "the research director"
faction = "Station"
diff --git a/code/modules/jobs/job_types/geneticist.dm b/code/modules/jobs/job_types/geneticist.dm
index 536225e540727..ea648f5b184b5 100644
--- a/code/modules/jobs/job_types/geneticist.dm
+++ b/code/modules/jobs/job_types/geneticist.dm
@@ -1,6 +1,8 @@
/datum/job/geneticist
title = JOB_NAME_GENETICIST
flag = GENETICIST
+ description = "Discover useful mutations and give them out to the crew at CMO's approval, oversee Cloning, create humanized monkeys for replacement organs and bodyparts if needed."
+ department_for_prefs = DEPT_BITFLAG_MED
department_head = list(JOB_NAME_CHIEFMEDICALOFFICER)
supervisors = "the chief medical officer"
faction = "Station"
diff --git a/code/modules/jobs/job_types/gimmick.dm b/code/modules/jobs/job_types/gimmick.dm
index a97a89740fbe4..832f0d9ef4f33 100644
--- a/code/modules/jobs/job_types/gimmick.dm
+++ b/code/modules/jobs/job_types/gimmick.dm
@@ -1,6 +1,9 @@
/datum/job/gimmick //gimmick var must be set to true for all gimmick jobs BUT the parent
title = JOB_NAME_GIMMICK
flag = GIMMICK
+ description = "Use your unique position to provide a service or entertain the crew."
+ department_for_prefs = DEPT_BITFLAG_ASSISTANT
+ show_in_prefs = TRUE
faction = "Station"
total_positions = 0
spawn_positions = 0
@@ -30,9 +33,11 @@
/datum/job/gimmick/barber
title = JOB_NAME_BARBER
flag = BARBER
+ description = "Give the crew haircuts using the variety of tools at your disposal, and provide less professional and cosmetic surgeries."
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
gimmick = TRUE
+ show_in_prefs = FALSE
outfit = /datum/outfit/job/gimmick/barber
@@ -63,9 +68,11 @@
/datum/job/gimmick/stage_magician
title = JOB_NAME_STAGEMAGICIAN
flag = MAGICIAN
+ description = "Use your special tools to provide entertainment for the crew, show them than you can do more than simple parlor magic tricks."
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
gimmick = TRUE
+ show_in_prefs = FALSE
outfit = /datum/outfit/job/gimmick/stage_magician
@@ -102,9 +109,11 @@
/datum/job/gimmick/psychiatrist
title = JOB_NAME_PSYCHIATRIST
flag = PSYCHIATRIST
+ description = "Provide therapy to the crew through talk sessions, psychoactive drugs, and careful consideration of their thoughts and feelings. Provide mental evaluations for Security."
department_head = list(JOB_NAME_CHIEFMEDICALOFFICER)
supervisors = "the chief medical officer"
gimmick = TRUE
+ show_in_prefs = FALSE
outfit = /datum/outfit/job/gimmick/psychiatrist
@@ -133,7 +142,9 @@
/datum/job/gimmick/vip
title = JOB_NAME_VIP
flag = CELEBRITY
+ description = "Flaunt around your wealth, organize posh parties and other high life activities with your near-bottomless budget."
gimmick = TRUE
+ show_in_prefs = FALSE
outfit = /datum/outfit/job/gimmick/vip
diff --git a/code/modules/jobs/job_types/head_of_personnel.dm b/code/modules/jobs/job_types/head_of_personnel.dm
index 5eab9c3208750..e4c2b236cdd84 100644
--- a/code/modules/jobs/job_types/head_of_personnel.dm
+++ b/code/modules/jobs/job_types/head_of_personnel.dm
@@ -1,7 +1,10 @@
/datum/job/head_of_personnel
title = JOB_NAME_HEADOFPERSONNEL
flag = HOP
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_HEAD
+ description = "Second in command on the station, oversee the crew assigned to service and cargo positions, handle department transfer requests by consulting relevant heads. Protect Ian at all costs."
+ department_for_prefs = DEPT_BITFLAG_CAPTAIN
+ department_head_for_prefs = JOB_NAME_CAPTAIN
+ auto_deadmin_role_flags = DEADMIN_POSITION_HEAD
department_head = list(JOB_NAME_CAPTAIN)
supervisors = "the captain"
head_announce = list(RADIO_CHANNEL_SUPPLY, RADIO_CHANNEL_SERVICE)
diff --git a/code/modules/jobs/job_types/head_of_security.dm b/code/modules/jobs/job_types/head_of_security.dm
index ea5391592b190..27848556b0f23 100644
--- a/code/modules/jobs/job_types/head_of_security.dm
+++ b/code/modules/jobs/job_types/head_of_security.dm
@@ -1,7 +1,9 @@
/datum/job/head_of_security
title = JOB_NAME_HEADOFSECURITY
flag = HOS
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_HEAD|PREFTOGGLE_DEADMIN_POSITION_SECURITY
+ description = "Oversee the members of security and ensure they follow Space Law. Deputize other crew members when the station is in need of additional protection."
+ department_for_prefs = DEPT_BITFLAG_SEC
+ auto_deadmin_role_flags = DEADMIN_POSITION_HEAD|DEADMIN_POSITION_SECURITY
department_head = list(JOB_NAME_CAPTAIN)
supervisors = "the captain"
head_announce = list(RADIO_CHANNEL_SECURITY)
diff --git a/code/modules/jobs/job_types/janitor.dm b/code/modules/jobs/job_types/janitor.dm
index 075fced5e720d..552e827768909 100644
--- a/code/modules/jobs/job_types/janitor.dm
+++ b/code/modules/jobs/job_types/janitor.dm
@@ -1,6 +1,8 @@
/datum/job/janitor
title = JOB_NAME_JANITOR
flag = JANITOR
+ description = "Clean up vomit, trash, and other messes around the station. Put down signs to warn people of slipping hazards, and eradicate rodents when you find them. Keep the station clean and tidy."
+ department_for_prefs = DEPT_BITFLAG_SRV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/lawyer.dm b/code/modules/jobs/job_types/lawyer.dm
index 51a56ac7d3f01..5eb9c3f3c3d30 100644
--- a/code/modules/jobs/job_types/lawyer.dm
+++ b/code/modules/jobs/job_types/lawyer.dm
@@ -1,6 +1,8 @@
/datum/job/lawyer
title = JOB_NAME_LAWYER
flag = LAWYER
+ description = "Ensure Security follows Space Law and Standard Operating Procedure perfectly, represent your clients in trials and other legal troubles, make sure the crew is treated fairly by the men in red."
+ department_for_prefs = DEPT_BITFLAG_CIV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/medical_doctor.dm b/code/modules/jobs/job_types/medical_doctor.dm
index aab24e4abfca2..fd4822379eb17 100644
--- a/code/modules/jobs/job_types/medical_doctor.dm
+++ b/code/modules/jobs/job_types/medical_doctor.dm
@@ -1,6 +1,8 @@
/datum/job/medical_doctor
title = JOB_NAME_MEDICALDOCTOR
flag = DOCTOR
+ description = "Treat people of both minor wounds, serious injuries and resurrect them from the dead. Make use of surgeries and surgical tools, Chemistry's pills and patches, Virology's viruses and in dire cases, Genetics' cloning."
+ department_for_prefs = DEPT_BITFLAG_MED
department_head = list(JOB_NAME_CHIEFMEDICALOFFICER)
supervisors = "the chief medical officer"
faction = "Station"
diff --git a/code/modules/jobs/job_types/mime.dm b/code/modules/jobs/job_types/mime.dm
index 91ffe4ba5a5aa..d143e82360f60 100644
--- a/code/modules/jobs/job_types/mime.dm
+++ b/code/modules/jobs/job_types/mime.dm
@@ -1,6 +1,8 @@
/datum/job/mime
title = JOB_NAME_MIME
flag = MIME
+ description = "Be the Clown's mute counterpart and arch nemesis. Conduct pantomimes and performances, create interesting situations with your mime powers. Remember your job is to keep things funny for others, not just yourself."
+ department_for_prefs = DEPT_BITFLAG_CIV
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
@@ -27,9 +29,14 @@
minimal_lightup_areas = list(/area/crew_quarters/theatre)
-/datum/job/mime/after_spawn(mob/living/carbon/human/H, mob/M)
+/datum/job/mime/after_spawn(mob/living/carbon/human/H, mob/M, latejoin = FALSE, client/preference_source, on_dummy = FALSE)
. = ..()
- H.apply_pref_name("mime", M.client)
+ if(!ishuman(H))
+ return
+ if(!M.client || on_dummy)
+ return
+ H.apply_pref_name(/datum/preference/name/mime, preference_source)
+
/datum/outfit/job/mime
name = JOB_NAME_MIME
diff --git a/code/modules/jobs/job_types/paramedic.dm b/code/modules/jobs/job_types/paramedic.dm
index 6df3f867dfdf2..a04e0856b6145 100644
--- a/code/modules/jobs/job_types/paramedic.dm
+++ b/code/modules/jobs/job_types/paramedic.dm
@@ -1,6 +1,8 @@
/datum/job/paramedic
title = JOB_NAME_PARAMEDIC
flag = PARAMEDIC
+ description = "Retrieve the gravely injured and dead people from around the station, deliver medicine for minor wounds, and keep a close eye on the Crew Monitor in your free time."
+ department_for_prefs = DEPT_BITFLAG_MED
department_head = list(JOB_NAME_CHIEFMEDICALOFFICER)
supervisors = "the chief medical officer"
faction = "Station"
diff --git a/code/modules/jobs/job_types/quartermaster.dm b/code/modules/jobs/job_types/quartermaster.dm
index a8dccf3dfabff..9417b5adec897 100644
--- a/code/modules/jobs/job_types/quartermaster.dm
+++ b/code/modules/jobs/job_types/quartermaster.dm
@@ -1,6 +1,8 @@
/datum/job/quartermaster
title = JOB_NAME_QUARTERMASTER
flag = QUARTERMASTER
+ description = "Oversee and direct cargo technicians to fulfill requests for supplies and keep the station well stocked, request funds from department budgets to cover costs, deny frivolous orders when money is tight, and sell anything the station doesn't need."
+ department_for_prefs = DEPT_BITFLAG_CAR
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/research_director.dm b/code/modules/jobs/job_types/research_director.dm
index a92ef370caf30..a5020b7923e3e 100644
--- a/code/modules/jobs/job_types/research_director.dm
+++ b/code/modules/jobs/job_types/research_director.dm
@@ -1,7 +1,9 @@
/datum/job/research_director
title = JOB_NAME_RESEARCHDIRECTOR
flag = RD_JF
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_HEAD
+ description = "Oversee the scientists and roboticists and keep up with their research projects, take care of any issues with the station's AI that may arise, ensure research is being prioritized in accordance with the needs of the station."
+ department_for_prefs = DEPT_BITFLAG_SCI
+ auto_deadmin_role_flags = DEADMIN_POSITION_HEAD
department_head = list(JOB_NAME_CAPTAIN)
supervisors = "the captain"
head_announce = list("Science")
diff --git a/code/modules/jobs/job_types/roboticist.dm b/code/modules/jobs/job_types/roboticist.dm
index f71d628273556..37b2b0bed8df9 100644
--- a/code/modules/jobs/job_types/roboticist.dm
+++ b/code/modules/jobs/job_types/roboticist.dm
@@ -1,6 +1,8 @@
/datum/job/roboticist
title = JOB_NAME_ROBOTICIST
flag = ROBOTICIST
+ description = "Create bots and utility mechs for helping out around the station. Construct war machines by the request of the Captain or Head of Security. Make new Cyborgs, give augmentations and implants to crew members."
+ department_for_prefs = DEPT_BITFLAG_SCI
department_head = list(JOB_NAME_RESEARCHDIRECTOR)
faction = "Station"
total_positions = 2
diff --git a/code/modules/jobs/job_types/scientist.dm b/code/modules/jobs/job_types/scientist.dm
index 33f237efe5bb6..e9f8164ad7620 100644
--- a/code/modules/jobs/job_types/scientist.dm
+++ b/code/modules/jobs/job_types/scientist.dm
@@ -1,6 +1,8 @@
/datum/job/scientist
title = JOB_NAME_SCIENTIST
flag = SCIENTIST
+ description = "Engage in Xenobiology, Xenoarchaeology, Nanites, and Toxins; research new technology; and upgrade the machine parts around the station."
+ department_for_prefs = DEPT_BITFLAG_SCI
department_head = list(JOB_NAME_RESEARCHDIRECTOR)
supervisors = "the research director"
faction = "Station"
diff --git a/code/modules/jobs/job_types/security_officer.dm b/code/modules/jobs/job_types/security_officer.dm
index 1857fbb00ed4a..88885ba498262 100644
--- a/code/modules/jobs/job_types/security_officer.dm
+++ b/code/modules/jobs/job_types/security_officer.dm
@@ -1,7 +1,9 @@
/datum/job/security_officer
title = JOB_NAME_SECURITYOFFICER
flag = OFFICER
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_SECURITY
+ description = "Follow Space Law, patrol the station, arrest criminals and bring them to the Brig."
+ department_for_prefs = DEPT_BITFLAG_SEC
+ auto_deadmin_role_flags = DEADMIN_POSITION_SECURITY
department_head = list(JOB_NAME_HEADOFSECURITY)
supervisors = "the head of security, and the head of your assigned department (if applicable)"
faction = "Station"
@@ -43,18 +45,19 @@
GLOBAL_LIST_INIT(available_depts, list(SEC_DEPT_ENGINEERING, SEC_DEPT_MEDICAL, SEC_DEPT_SCIENCE, SEC_DEPT_SUPPLY))
-/datum/job/security_officer/after_spawn(mob/living/carbon/human/H, mob/M)
+/datum/job/security_officer/after_spawn(mob/living/carbon/human/H, mob/M, latejoin = FALSE, client/preference_source, on_dummy = FALSE)
. = ..()
// Assign department security
var/department
- if(M?.client?.prefs)
- department = M.client.prefs.active_character.preferred_security_department
+ if(preference_source?.prefs)
+ department = preference_source.prefs.read_character_preference(/datum/preference/choiced/security_department)
if(!LAZYLEN(GLOB.available_depts) || department == "None")
return
- else if(department in GLOB.available_depts)
- LAZYREMOVE(GLOB.available_depts, department)
- else
- department = pick_n_take(GLOB.available_depts)
+ if(!on_dummy && M.client) // The dummy should just use the preference always, and not remove departments.
+ if(department in GLOB.available_depts)
+ LAZYREMOVE(GLOB.available_depts, department)
+ else
+ department = pick_n_take(GLOB.available_depts)
var/ears = null
var/accessory = null
var/list/dep_access = null
@@ -63,32 +66,36 @@ GLOBAL_LIST_INIT(available_depts, list(SEC_DEPT_ENGINEERING, SEC_DEPT_MEDICAL, S
switch(department)
if(SEC_DEPT_SUPPLY)
ears = /obj/item/radio/headset/headset_sec/alt/department/supply
- dep_access = list(ACCESS_MAILSORTING, ACCESS_MINING, ACCESS_MINING_STATION, ACCESS_CARGO, ACCESS_AUX_BASE)
- destination = /area/security/checkpoint/supply
- spawn_point = locate(/obj/effect/landmark/start/depsec/supply) in GLOB.department_security_spawns
accessory = /obj/item/clothing/accessory/armband/cargo
- minimal_lightup_areas |= GLOB.supply_lightup_areas
+ if(!on_dummy)
+ destination = /area/security/checkpoint/supply
+ dep_access = list(ACCESS_MAILSORTING, ACCESS_MINING, ACCESS_MINING_STATION, ACCESS_CARGO, ACCESS_AUX_BASE)
+ spawn_point = locate(/obj/effect/landmark/start/depsec/supply) in GLOB.department_security_spawns
+ minimal_lightup_areas |= GLOB.supply_lightup_areas
if(SEC_DEPT_ENGINEERING)
ears = /obj/item/radio/headset/headset_sec/alt/department/engi
- dep_access = list(ACCESS_CONSTRUCTION, ACCESS_ENGINE, ACCESS_ATMOSPHERICS, ACCESS_AUX_BASE)
- destination = /area/security/checkpoint/engineering
- spawn_point = locate(/obj/effect/landmark/start/depsec/engineering) in GLOB.department_security_spawns
accessory = /obj/item/clothing/accessory/armband/engine
- minimal_lightup_areas |= GLOB.engineering_lightup_areas
+ if(!on_dummy)
+ dep_access = list(ACCESS_CONSTRUCTION, ACCESS_ENGINE, ACCESS_ATMOSPHERICS, ACCESS_AUX_BASE)
+ destination = /area/security/checkpoint/engineering
+ spawn_point = locate(/obj/effect/landmark/start/depsec/engineering) in GLOB.department_security_spawns
+ minimal_lightup_areas |= GLOB.engineering_lightup_areas
if(SEC_DEPT_MEDICAL)
ears = /obj/item/radio/headset/headset_sec/alt/department/med
- dep_access = list(ACCESS_MEDICAL, ACCESS_MORGUE, ACCESS_SURGERY, ACCESS_CLONING)
- destination = /area/security/checkpoint/medical
- spawn_point = locate(/obj/effect/landmark/start/depsec/medical) in GLOB.department_security_spawns
accessory = /obj/item/clothing/accessory/armband/medblue
- minimal_lightup_areas |= GLOB.medical_lightup_areas
+ if(!on_dummy)
+ dep_access = list(ACCESS_MEDICAL, ACCESS_MORGUE, ACCESS_SURGERY, ACCESS_CLONING)
+ destination = /area/security/checkpoint/medical
+ spawn_point = locate(/obj/effect/landmark/start/depsec/medical) in GLOB.department_security_spawns
+ minimal_lightup_areas |= GLOB.medical_lightup_areas
if(SEC_DEPT_SCIENCE)
ears = /obj/item/radio/headset/headset_sec/alt/department/sci
- dep_access = list(ACCESS_RESEARCH, ACCESS_TOX, ACCESS_AUX_BASE)
- destination = /area/security/checkpoint/science
- spawn_point = locate(/obj/effect/landmark/start/depsec/science) in GLOB.department_security_spawns
accessory = /obj/item/clothing/accessory/armband/science
- minimal_lightup_areas |= GLOB.science_lightup_areas
+ if(!on_dummy)
+ dep_access = list(ACCESS_RESEARCH, ACCESS_TOX, ACCESS_AUX_BASE)
+ destination = /area/security/checkpoint/science
+ spawn_point = locate(/obj/effect/landmark/start/depsec/science) in GLOB.department_security_spawns
+ minimal_lightup_areas |= GLOB.science_lightup_areas
if(accessory)
var/obj/item/clothing/under/U = H.w_uniform
@@ -101,6 +108,9 @@ GLOBAL_LIST_INIT(available_depts, list(SEC_DEPT_ENGINEERING, SEC_DEPT_MEDICAL, S
var/obj/item/card/id/W = H.wear_id
W.access |= dep_access
+ if(!M.client || on_dummy)
+ return
+
var/teleport = 0
if(!CONFIG_GET(flag/sec_start_brig))
if(destination || spawn_point)
diff --git a/code/modules/jobs/job_types/shaft_miner.dm b/code/modules/jobs/job_types/shaft_miner.dm
index 314445b7fc3c5..738462cbfa3f5 100644
--- a/code/modules/jobs/job_types/shaft_miner.dm
+++ b/code/modules/jobs/job_types/shaft_miner.dm
@@ -1,6 +1,9 @@
/datum/job/shaft_miner
title = JOB_NAME_SHAFTMINER
flag = MINER
+ description = "Collect resources for the station, redeem them for points, and purchase gear to collect even more ores."
+ department_for_prefs = DEPT_BITFLAG_CAR
+ department_head_for_prefs = JOB_NAME_QUARTERMASTER
department_head = list(JOB_NAME_HEADOFPERSONNEL)
supervisors = "the quartermaster and the head of personnel"
faction = "Station"
diff --git a/code/modules/jobs/job_types/station_engineer.dm b/code/modules/jobs/job_types/station_engineer.dm
index 780141a65f928..14ef73e1bd243 100644
--- a/code/modules/jobs/job_types/station_engineer.dm
+++ b/code/modules/jobs/job_types/station_engineer.dm
@@ -1,6 +1,8 @@
/datum/job/station_engineer
title = JOB_NAME_STATIONENGINEER
flag = ENGINEER
+ description = "Ensure the station has an adequate power supply, repair and build new machinery, repair wiring chewed up by mice."
+ department_for_prefs = DEPT_BITFLAG_ENG
department_head = list(JOB_NAME_CHIEFENGINEER)
supervisors = "the chief engineer"
faction = "Station"
diff --git a/code/modules/jobs/job_types/virologist.dm b/code/modules/jobs/job_types/virologist.dm
index 2c6c40b676c65..f08be796a457c 100644
--- a/code/modules/jobs/job_types/virologist.dm
+++ b/code/modules/jobs/job_types/virologist.dm
@@ -1,6 +1,8 @@
/datum/job/virologist
title = JOB_NAME_VIROLOGIST
flag = VIROLOGIST
+ description = "Collect virus samples from dormant viruses, old blood, and crusty vomit from around the station, isolate the symptoms and use them to create useful healing viruses for the crew."
+ department_for_prefs = DEPT_BITFLAG_MED
department_head = list(JOB_NAME_CHIEFMEDICALOFFICER)
supervisors = "the chief medical officer"
faction = "Station"
diff --git a/code/modules/jobs/job_types/warden.dm b/code/modules/jobs/job_types/warden.dm
index ba1714aca57d5..7cca6e4821167 100644
--- a/code/modules/jobs/job_types/warden.dm
+++ b/code/modules/jobs/job_types/warden.dm
@@ -1,7 +1,9 @@
/datum/job/warden
title = JOB_NAME_WARDEN
flag = WARDEN
- auto_deadmin_role_flags = PREFTOGGLE_DEADMIN_POSITION_SECURITY
+ description = "Oversee prisoners in the brig and guard the armory. Hand out equipment when necessary and ensure it is returned after threats have been contained."
+ department_for_prefs = DEPT_BITFLAG_SEC
+ auto_deadmin_role_flags = DEADMIN_POSITION_SECURITY
department_head = list(JOB_NAME_HEADOFSECURITY)
supervisors = "the head of security"
faction = "Station"
diff --git a/code/modules/keybindings/bindings_client.dm b/code/modules/keybindings/bindings_client.dm
index 5041c5f864518..6bcf76703e21c 100644
--- a/code/modules/keybindings/bindings_client.dm
+++ b/code/modules/keybindings/bindings_client.dm
@@ -52,15 +52,18 @@ GLOBAL_LIST_INIT(valid_keys, list(
// Client-level keybindings are ones anyone should be able to do at any time
// Things like taking screenshots, hitting tab, and adminhelps.
- var/AltMod = keys_held["Alt"] ? "Alt-" : ""
- var/CtrlMod = keys_held["Ctrl"] ? "Ctrl-" : ""
- var/ShiftMod = keys_held["Shift"] ? "Shift-" : ""
- var/full_key = "[_key]"
- if (!(_key in list("Alt", "Ctrl", "Shift")))
- full_key = "[AltMod][CtrlMod][ShiftMod][_key]"
+ var/AltMod = keys_held["Alt"] ? "Alt" : ""
+ var/CtrlMod = keys_held["Ctrl"] ? "Ctrl" : ""
+ var/ShiftMod = keys_held["Shift"] ? "Shift" : ""
+ var/full_key
+ switch(_key)
+ if("Alt", "Ctrl", "Shift")
+ full_key = "[AltMod][CtrlMod][ShiftMod]"
+ else
+ full_key = "[AltMod][CtrlMod][ShiftMod][_key]"
var/list/kbs = list()
- for (var/kb_name in prefs.key_bindings[full_key])
+ for (var/kb_name in prefs.key_bindings_by_key[full_key])
var/datum/keybinding/kb = GLOB.keybindings_by_name[kb_name]
kbs += kb
// WASD-type movement keys (not the native arrow keys) are handled through the keybind system here.
@@ -68,7 +71,7 @@ GLOBAL_LIST_INIT(valid_keys, list(
// since these modifier keys toggle effects like "change facing" that require the movement keys to function.
// Note that this doesn't prevent the user from binding CTRL-W to North: In that case *only* CTRL-W will function.
if (full_key != _key)
- for (var/kb_name in prefs.key_bindings[_key])
+ for (var/kb_name in prefs.key_bindings_by_key[_key])
var/datum/keybinding/kb = GLOB.keybindings_by_name[kb_name]
if (kb.any_modifier)
kbs += kb
@@ -77,10 +80,8 @@ GLOBAL_LIST_INIT(valid_keys, list(
if(kb.can_use(src) && kb.down(src))
break
- if(holder)
- holder.key_down(_key, src) //full_key is not necessary here, _key is enough
- if(mob.focus)
- mob.focus.key_down(_key, src) //same as above
+ holder?.key_down(_key, src) //full_key is not necessary here, _key is enough
+ mob.focus?.key_down(_key, src) //same as above
/client/verb/keyUp(_key as text)
set instant = TRUE
@@ -97,7 +98,7 @@ GLOBAL_LIST_INIT(valid_keys, list(
// We don't do full key for release, because for mod keys you
// can hold different keys and releasing any should be handled by the key binding specifically
var/list/kbs = list()
- for (var/kb_name in prefs.key_bindings[_key])
+ for (var/kb_name in prefs.key_bindings_by_key[_key])
var/datum/keybinding/kb = GLOB.keybindings_by_name[kb_name]
kbs += kb
kbs = sort_list(kbs, GLOBAL_PROC_REF(cmp_keybinding_dsc))
@@ -105,7 +106,5 @@ GLOBAL_LIST_INIT(valid_keys, list(
if(kb.can_use(src) && kb.up(src))
break
- if(holder)
- holder.key_up(_key, src)
- if(mob.focus)
- mob.focus.key_up(_key, src)
+ holder?.key_up(_key, src)
+ mob.focus?.key_up(_key, src)
diff --git a/code/modules/keybindings/setup.dm b/code/modules/keybindings/setup.dm
index 01a989265915e..c29ec3534375e 100644
--- a/code/modules/keybindings/setup.dm
+++ b/code/modules/keybindings/setup.dm
@@ -37,7 +37,7 @@
erase_all_macros()
var/list/macro_sets = SSinput.macro_sets
- var/use_tgui_say = !prefs || (prefs.toggles2 & PREFTOGGLE_2_TGUI_SAY)
+ var/use_tgui_say = !prefs || (prefs.read_player_preference(/datum/preference/toggle/tgui_say))
var/say = use_tgui_say ? tgui_say_create_open_command(SAY_CHANNEL) : "\".winset \\\"command=\\\".start_typing say\\\";command=.init_say;saywindow.is-visible=true;saywindow.input.focus=true\\\"\""
var/me = use_tgui_say ? tgui_say_create_open_command(ME_CHANNEL) : "\".winset \\\"command=\\\".start_typing me\\\";command=.init_me;mewindow.is-visible=true;mewindow.input.focus=true\\\"\""
var/ooc = use_tgui_say ? tgui_say_create_open_command(OOC_CHANNEL) : "ooc"
@@ -61,7 +61,7 @@
winset(src, "[setname]-close-tgui-say", "parent=[setname];name=Escape;command=[tgui_say_create_close_command()]")
- if(prefs.toggles2 & PREFTOGGLE_2_HOTKEYS)
+ if(hotkeys)
winset(src, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED] mainwindow.macro=default")
else
winset(src, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED] mainwindow.macro=old_default")
diff --git a/code/modules/language/language_holder.dm b/code/modules/language/language_holder.dm
index 0d6a6cb191f95..daef70de00449 100644
--- a/code/modules/language/language_holder.dm
+++ b/code/modules/language/language_holder.dm
@@ -62,7 +62,9 @@ Key procs
if(M.current)
update_atom_languages(M.current)
grant_language(/datum/language/metalanguage, understood=TRUE, spoken=FALSE, source=LANGUAGE_MIND) // Gets metalanguage that you can only understand
- get_selected_language()
+ // If we have an owner, we'll set a default selected language
+ if(owner)
+ get_selected_language()
/datum/language_holder/Destroy()
QDEL_NULL(language_menu)
diff --git a/code/modules/mining/equipment/regenerative_core.dm b/code/modules/mining/equipment/regenerative_core.dm
index 2fb07c0fd390f..108dc6d6e6ac6 100644
--- a/code/modules/mining/equipment/regenerative_core.dm
+++ b/code/modules/mining/equipment/regenerative_core.dm
@@ -109,13 +109,13 @@
if(user.canUseTopic(src, BE_CLOSE, FALSE, NO_TK))
applyto(user, user)
-/obj/item/organ/regenerative_core/Insert(mob/living/carbon/M, special = 0, drop_if_replaced = TRUE)
+/obj/item/organ/regenerative_core/Insert(mob/living/carbon/M, special = 0, drop_if_replaced = TRUE, pref_load = FALSE)
. = ..()
if(!preserved && !inert)
preserved(TRUE)
owner.visible_message("[src] stabilizes as it's inserted.")
-/obj/item/organ/regenerative_core/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/regenerative_core/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
if(!inert && !special)
owner.visible_message("[src] rapidly decays as it's removed.")
go_inert()
diff --git a/code/modules/mob/dead/new_player/new_player.dm b/code/modules/mob/dead/new_player/new_player.dm
index 8cf5aa22b4fd7..71554f6644dd9 100644
--- a/code/modules/mob/dead/new_player/new_player.dm
+++ b/code/modules/mob/dead/new_player/new_player.dm
@@ -119,7 +119,10 @@
relevant_cap = max(hpc, epc)
if(href_list["show_preferences"])
- client.prefs.ShowChoices(src)
+ var/datum/preferences/preferences = client.prefs
+ preferences.current_window = PREFERENCE_TAB_CHARACTER_PREFERENCES
+ preferences.update_static_data(usr)
+ preferences.ui_interact(usr)
return 1
if(href_list["ready"])
@@ -149,7 +152,7 @@
return
if(SSticker.queued_players.len || (relevant_cap && living_player_count() >= relevant_cap))
- if(IS_PATRON(src.ckey) || (client in GLOB.admins))
+ if(IS_PATRON(src.ckey) || is_admin(src.ckey))
LateChoices()
return
to_chat(usr, "[CONFIG_GET(string/hard_popcap_message)]")
@@ -177,7 +180,7 @@
to_chat(usr, "There is an administrative lock on entering the game!")
return
- if(SSticker.queued_players.len && !(ckey(key) in GLOB.admin_datums) && !IS_PATRON(ckey(key)))
+ if(SSticker.queued_players.len && !is_admin(ckey(key)) && !IS_PATRON(ckey(key)))
if((living_player_count() >= relevant_cap) || (src != SSticker.queued_players[1]))
to_chat(usr, "Server is full.")
return
@@ -232,7 +235,7 @@
observer.client = client
observer.set_ghost_appearance()
if(observer.client && observer.client.prefs)
- observer.real_name = observer.client.prefs.active_character.real_name
+ observer.real_name = observer.client.prefs.read_character_preference(/datum/preference/name/real_name)
observer.name = observer.real_name
observer.update_icon()
observer.stop_sound_channel(CHANNEL_LOBBYMUSIC)
@@ -438,22 +441,16 @@
popup.set_content(jointext(dat, ""))
popup.open(FALSE) // 0 is passed to open so that it doesn't use the onclose() proc
+/// Creates, assigns and returns the new_character to spawn as. Assumes a valid mind.assigned_role exists.
/mob/dead/new_player/proc/create_character(transfer_after)
- spawning = 1
+ spawning = TRUE
close_spawn_windows()
var/mob/living/carbon/human/H = new(loc)
- var/frn = CONFIG_GET(flag/force_random_names)
- if(!frn)
- frn = is_banned_from(ckey, "Appearance")
- if(QDELETED(src))
- return
- if(frn)
- client.prefs.active_character.randomise()
- client.prefs.active_character.real_name = client.prefs.active_character.pref_species.random_name(gender,1)
- client.prefs.active_character.copy_to(H)
- H.dna.update_dna_identity()
+ H.apply_prefs_job(client, SSjob.GetJob(mind.assigned_role))
+ if(QDELETED(src) || !client)
+ return // Disconnected while checking for the appearance ban.
if(mind)
if(transfer_after)
mind.late_joiner = TRUE
@@ -508,11 +505,11 @@
/mob/dead/new_player/proc/check_preferences()
if(!client)
return FALSE //Not sure how this would get run without the mob having a client, but let's just be safe.
- if(client.prefs.active_character.joblessrole != RETURNTOLOBBY)
+ if(client.prefs.read_player_preference(/datum/preference/choiced/jobless_role) != RETURNTOLOBBY)
return TRUE
// If they have antags enabled, they're potentially doing this on purpose instead of by accident. Notify admins if so.
- var/has_antags = (length(client.prefs.role_preferences) + length(client.prefs.active_character?.role_preferences_character)) > 0
- if(!length(client.prefs.active_character.job_preferences))
+ var/has_antags = (length(client.prefs.role_preferences_global) + length(client.prefs.role_preferences)) > 0
+ if(!length(client.prefs.job_preferences))
if(!ineligible_for_roles)
to_chat(src, "You have no jobs enabled, along with return to lobby if job is unavailable. This makes you ineligible for any round start role, please update your job preferences.")
ineligible_for_roles = TRUE
diff --git a/code/modules/mob/dead/new_player/sprite_accessories.dm b/code/modules/mob/dead/new_player/sprite_accessories.dm
index 2154149775e8a..25fee55276667 100644
--- a/code/modules/mob/dead/new_player/sprite_accessories.dm
+++ b/code/modules/mob/dead/new_player/sprite_accessories.dm
@@ -74,6 +74,17 @@
// try to spell
// you do not need to define _s or _l sub-states, game automatically does this for you
+/// Don't move these two, they go first
+/datum/sprite_accessory/hair/bald
+ name = "Bald"
+ icon_state = null
+
+/datum/sprite_accessory/hair/bald2
+ name = "Bald 2"
+ icon_state = "hair_bald2"
+
+// --------
+
/datum/sprite_accessory/hair/afro
name = "Afro"
icon_state = "hair_afro"
@@ -90,14 +101,6 @@
name = "Ahoge"
icon_state = "hair_antenna"
-/datum/sprite_accessory/hair/bald
- name = "Bald"
- icon_state = null
-
-/datum/sprite_accessory/hair/bald2
- name = "Bald 2"
- icon_state = "hair_bald2"
-
/datum/sprite_accessory/hair/balding
name = "Balding Hair"
icon_state = "hair_e"
@@ -907,6 +910,12 @@
// please make sure they're sorted alphabetically and categorized
+/// This one goes first. Don't move it
+/datum/sprite_accessory/facial_hair/shaved
+ name = "Shaved"
+ icon_state = null
+ gender = NEUTER
+
/datum/sprite_accessory/facial_hair/eyebrows
name = "Eyebrows"
icon_state = "facial_eyebrows"
@@ -935,7 +944,6 @@
name = "Beard (Cropped Fullbeard)"
icon_state = "facial_croppedfullbeard"
-
/datum/sprite_accessory/facial_hair/gt
name = "Beard (Goatee)"
icon_state = "facial_gt"
@@ -1068,11 +1076,6 @@
name = "Sideburns"
icon_state = "facial_sideburn"
-/datum/sprite_accessory/facial_hair/shaved
- name = "Shaved"
- icon_state = null
- gender = NEUTER
-
///////////////////////////
// Underwear Definitions //
///////////////////////////
@@ -1848,19 +1851,21 @@
/datum/sprite_accessory/wings/apid
name = "Bee"
+ icon = 'icons/mob/apid_accessories/apid_wings.dmi'
icon_state = "apid"
color_src = 0
- dimension_x = 46
+ dimension_x = 32
center = TRUE
- dimension_y = 34
+ dimension_y = 32
/datum/sprite_accessory/wings_open/apid
name = "Bee"
+ icon = 'icons/mob/apid_accessories/apid_wings.dmi'
icon_state = "apid"
color_src = 0
- dimension_x = 46
+ dimension_x = 32
center = TRUE
- dimension_y = 34
+ dimension_y = 32
/datum/sprite_accessory/wings/robot
name = "Robot"
@@ -2401,7 +2406,7 @@
/datum/sprite_accessory/ipc_antennas/none
name = "None"
- icon_state = "None"
+ icon_state = "none"
/datum/sprite_accessory/ipc_antennas/angled
name = "Angled"
@@ -2452,10 +2457,12 @@
/datum/sprite_accessory/insect_type/fly
name = "Common Fly"
limbs_id = "fly"
+ gender_specific = FALSE
/datum/sprite_accessory/insect_type/bee
name = "Hoverfly"
limbs_id = "bee"
+ gender_specific = TRUE
/datum/sprite_accessory/ipc_chassis/mcgreyscale
name = "Morpheus Cyberkinetics (Custom)"
diff --git a/code/modules/mob/dead/observer/login.dm b/code/modules/mob/dead/observer/login.dm
index 5550a454d5c54..9bb48f0c46380 100644
--- a/code/modules/mob/dead/observer/login.dm
+++ b/code/modules/mob/dead/observer/login.dm
@@ -1,16 +1,16 @@
/mob/dead/observer/Login()
..()
- ghost_accs = client.prefs.ghost_accs
- ghost_others = client.prefs.ghost_others
+ ghost_accs = client.prefs.read_player_preference(/datum/preference/choiced/ghost_accessories)
+ ghost_others = client.prefs.read_player_preference(/datum/preference/choiced/ghost_others)
var/preferred_form = null
if(IsAdminGhost(src))
has_unlimited_silicon_privilege = 1
if(client.prefs.unlock_content)
- preferred_form = client.prefs.ghost_form
- ghost_orbit = client.prefs.ghost_orbit
+ preferred_form = client.prefs.read_player_preference(/datum/preference/choiced/ghost_form)
+ ghost_orbit = client.prefs.read_player_preference(/datum/preference/choiced/ghost_orbit)
var/turf/T = get_turf(src)
if (isturf(T))
diff --git a/code/modules/mob/dead/observer/observer.dm b/code/modules/mob/dead/observer/observer.dm
index 387fbfd837d8f..fdc5d2ef3d82b 100644
--- a/code/modules/mob/dead/observer/observer.dm
+++ b/code/modules/mob/dead/observer/observer.dm
@@ -200,9 +200,10 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_SPIRIT)
*/
/mob/dead/observer/update_icon(updates = ALL, new_form)
. = ..()
- if(client) //We update our preferences in case they changed right before update_icon was called.
- ghost_accs = client.prefs.ghost_accs
- ghost_others = client.prefs.ghost_others
+
+ if(client) //We update our preferences in case they changed right before update_appearance was called.
+ ghost_accs = client.prefs.read_player_preference(/datum/preference/choiced/ghost_accessories)
+ ghost_others = client.prefs.read_player_preference(/datum/preference/choiced/ghost_others)
if(hair_overlay)
cut_overlay(hair_overlay)
@@ -220,7 +221,7 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_SPIRIT)
else
ghostimage_default.icon_state = new_form
- if(ghost_accs >= GHOST_ACCS_DIR && (icon_state in GLOB.ghost_forms_with_directions_list)) //if this icon has dirs AND the client wants to show them, we make sure we update the dir on movement
+ if((ghost_accs == GHOST_ACCS_DIR || ghost_accs == GHOST_ACCS_FULL) && (icon_state in GLOB.ghost_forms_with_directions_list)) //if this icon has dirs AND the client wants to show them, we make sure we update the dir on movement
updatedir = 1
else
updatedir = 0 //stop updating the dir in case we want to show accessories with dirs on a ghost sprite without dirs
@@ -397,8 +398,9 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp
if(source)
var/atom/movable/screen/alert/A = throw_alert("[REF(source)]_notify_cloning", /atom/movable/screen/alert/notify_cloning)
if(A)
- if(client && client.prefs && client.prefs.UI_style)
- A.icon = ui_style2icon(client.prefs.UI_style)
+ var/ui_style = client?.prefs?.read_player_preference(/datum/preference/choiced/ui_style)
+ if(ui_style)
+ A.icon = ui_style2icon(ui_style)
A.desc = message
var/old_layer = source.layer
var/old_plane = source.plane
@@ -582,7 +584,7 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp
/mob/dead/observer/update_sight()
if(client)
- ghost_others = client.prefs.ghost_others //A quick update just in case this setting was changed right before calling the proc
+ ghost_others = client.prefs.read_player_preference(/datum/preference/choiced/ghost_others) //A quick update just in case this setting was changed right before calling the proc
if (!ghostvision)
see_invisible = SEE_INVISIBLE_LIVING
@@ -610,11 +612,11 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp
client.images -= GLOB.ghost_images_default
if(GHOST_OTHERS_SIMPLE)
client.images -= GLOB.ghost_images_simple
- lastsetting = client.prefs.ghost_others
+ lastsetting = client.prefs.read_player_preference(/datum/preference/choiced/ghost_others)
if(!ghostvision)
return
- if(client.prefs.ghost_others != GHOST_OTHERS_THEIR_SETTING)
- switch(client.prefs.ghost_others)
+ if(lastsetting != GHOST_OTHERS_THEIR_SETTING)
+ switch(lastsetting)
if(GHOST_OTHERS_DEFAULT_SPRITE)
client.images |= (GLOB.ghost_images_default-ghostimage_default)
if(GHOST_OTHERS_SIMPLE)
@@ -790,26 +792,30 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp
set_ghost_appearance()
if(client?.prefs)
- deadchat_name = client.prefs.active_character.real_name
+ var/real_name = client.prefs.read_character_preference(/datum/preference/name/real_name)
+ deadchat_name = real_name
if(mind)
- mind.ghostname = client.prefs.active_character.real_name
- name = client.prefs.active_character.real_name
+ mind.ghostname = real_name
+ name = real_name
/mob/dead/observer/proc/set_ghost_appearance()
- if((!client) || (!client.prefs))
+ if(!client?.prefs)
return
- if(client.prefs.active_character.be_random_name)
- client.prefs.active_character.real_name = random_unique_name(gender)
- if(client.prefs.active_character.be_random_body)
- client.prefs.active_character.randomise(gender)
-
- if(HAIR in client.prefs.active_character.pref_species.species_traits)
- hair_style = client.prefs.active_character.hair_style
- hair_color = brighten_color(client.prefs.active_character.hair_color)
- if(FACEHAIR in client.prefs.active_character.pref_species.species_traits)
- facial_hair_style = client.prefs.active_character.facial_hair_style
- facial_hair_color = brighten_color(client.prefs.active_character.facial_hair_color)
+ client.prefs.apply_character_randomization_prefs()
+
+ var/species_type = client.prefs.read_character_preference(/datum/preference/choiced/species)
+ var/datum/species/species = new species_type
+
+ if(HAIR in species.species_traits)
+ hair_style = client.prefs.read_character_preference(/datum/preference/choiced/hairstyle)
+ hair_color = brighten_color(client.prefs.read_character_preference(/datum/preference/color_legacy/hair_color))
+
+ if(FACEHAIR in species.species_traits)
+ facial_hair_style = client.prefs.read_character_preference(/datum/preference/choiced/facial_hairstyle)
+ facial_hair_color = brighten_color(client.prefs.read_character_preference(/datum/preference/color_legacy/facial_hair_color))
+
+ qdel(species)
update_icon()
diff --git a/code/modules/mob/living/brain/brain.dm b/code/modules/mob/living/brain/brain.dm
index b99ad4137d193..3536574d11252 100644
--- a/code/modules/mob/living/brain/brain.dm
+++ b/code/modules/mob/living/brain/brain.dm
@@ -21,7 +21,7 @@
/mob/living/brain/proc/create_dna()
stored_dna = new /datum/dna/stored(src)
if(!stored_dna.species)
- var/rando_race = pick(GLOB.roundstart_races)
+ var/rando_race = pick(get_selectable_species())
stored_dna.species = new rando_race()
/mob/living/brain/Destroy()
diff --git a/code/modules/mob/living/brain/brain_item.dm b/code/modules/mob/living/brain/brain_item.dm
index 4a62877b93bd1..e9be3a75242ab 100644
--- a/code/modules/mob/living/brain/brain_item.dm
+++ b/code/modules/mob/living/brain/brain_item.dm
@@ -28,7 +28,7 @@
investigate_flags = ADMIN_INVESTIGATE_TARGET
-/obj/item/organ/brain/Insert(mob/living/carbon/C, special = 0,no_id_transfer = FALSE)
+/obj/item/organ/brain/Insert(mob/living/carbon/C, special = 0,no_id_transfer = FALSE, pref_load = FALSE)
..()
name = "brain"
@@ -59,7 +59,7 @@
//Update the body's icon so it doesnt appear debrained anymore
C.update_hair()
-/obj/item/organ/brain/Remove(mob/living/carbon/C, special = 0, no_id_transfer = FALSE)
+/obj/item/organ/brain/Remove(mob/living/carbon/C, special = 0, no_id_transfer = FALSE, pref_load = FALSE)
..()
for(var/X in traumas)
var/datum/brain_trauma/BT = X
diff --git a/code/modules/mob/living/carbon/alien/organs.dm b/code/modules/mob/living/carbon/alien/organs.dm
index 3e1541526109c..12d13bd80e821 100644
--- a/code/modules/mob/living/carbon/alien/organs.dm
+++ b/code/modules/mob/living/carbon/alien/organs.dm
@@ -14,12 +14,12 @@
QDEL_LIST(alien_powers)
return ..()
-/obj/item/organ/alien/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/alien/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
. = ..()
for(var/obj/effect/proc_holder/alien/P in alien_powers)
M.AddAbility(P)
-/obj/item/organ/alien/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/alien/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
for(var/obj/effect/proc_holder/alien/P in alien_powers)
M.RemoveAbility(P)
return ..()
@@ -82,14 +82,14 @@
else
owner.adjustPlasma(plasma_rate * 0.1)
-/obj/item/organ/alien/plasmavessel/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/alien/plasmavessel/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
. = ..()
if(!isalien(M))
return
var/mob/living/carbon/alien/A = M
A.updatePlasmaDisplay()
-/obj/item/organ/alien/plasmavessel/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/alien/plasmavessel/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
. = ..()
if(!isalien(M))
return
@@ -107,12 +107,12 @@
alien_powers = list(/obj/effect/proc_holder/alien/whisper)
var/recent_queen_death = 0 //Indicates if the queen died recently, aliens are heavily weakened while this is active.
-/obj/item/organ/alien/hivenode/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/alien/hivenode/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
M.faction |= FACTION_ALIEN
ADD_TRAIT(M, TRAIT_XENO_IMMUNE, "xeno immune")
return ..()
-/obj/item/organ/alien/hivenode/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/alien/hivenode/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
M.faction -= FACTION_ALIEN
REMOVE_TRAIT(M, TRAIT_XENO_IMMUNE, "xeno immune")
return ..()
diff --git a/code/modules/mob/living/carbon/human/dummy.dm b/code/modules/mob/living/carbon/human/dummy.dm
index 8d9d918de26a8..0ea12e3baea5f 100644
--- a/code/modules/mob/living/carbon/human/dummy.dm
+++ b/code/modules/mob/living/carbon/human/dummy.dm
@@ -7,6 +7,13 @@
INITIALIZE_IMMEDIATE(/mob/living/carbon/human/dummy)
+/mob/living/carbon/human/dummy/Initialize(mapload)
+ . = ..()
+ remove_from_all_data_huds()
+
+/mob/living/carbon/human/dummy/prepare_data_huds()
+ return
+
/mob/living/carbon/human/dummy/Destroy()
in_use = FALSE
return ..()
@@ -17,12 +24,43 @@ INITIALIZE_IMMEDIATE(/mob/living/carbon/human/dummy)
/mob/living/carbon/human/dummy/proc/wipe_state()
delete_equipment()
cut_overlays()
+ // Wipe anything from custom icon appearances (AI/cyborg)
+ icon = initial(icon)
+ icon_state = initial(icon_state)
/mob/living/carbon/human/dummy/setup_human_dna()
create_dna(src)
randomize_human(src)
dna.initialize_dna(skip_index = TRUE) //Skip stuff that requires full round init.
+/// Provides a dummy that is consistently bald, white, naked, etc.
+/mob/living/carbon/human/dummy/consistent
+
+/mob/living/carbon/human/dummy/consistent/setup_human_dna()
+ create_dna(src)
+ dna.initialize_dna(skip_index = TRUE)
+ dna.features["body_markings"] = "None"
+ dna.features["ears"] = "Cat"
+ dna.features["ethcolor"] = GLOB.color_list_ethereal["Cyan"]
+ dna.features["frills"] = "None"
+ dna.features["horns"] = "None"
+ dna.features["mcolor"] = "4c4"
+ dna.features["moth_antennae"] = "Plain"
+ dna.features["moth_markings"] = "None"
+ dna.features["moth_wings"] = "Plain"
+ dna.features["snout"] = "Round"
+ dna.features["spines"] = "None"
+ dna.features["tail_human"] = "Cat"
+ dna.features["tail_lizard"] = "Smooth"
+ dna.features["apid_stripes"] = "thick"
+ dna.features["apid_headstripes"] = "thick"
+ dna.features["apid_antenna"] = "curled"
+ dna.features["insect_type"] = "fly"
+ dna.features["ipc_screen"] = "BSOD"
+ dna.features["ipc_antenna"] = "None"
+ dna.features["ipc_chassis"] = "Morpheus Cyberkinetics (Custom)"
+ dna.features["psyphoza_cap"] = "wide"
+
//Inefficient pooling/caching way.
GLOBAL_LIST_EMPTY(human_dummy_list)
GLOBAL_LIST_EMPTY(dummy_mob_list)
diff --git a/code/modules/mob/living/carbon/human/human.dm b/code/modules/mob/living/carbon/human/human.dm
index bd7e140bf2297..b3bd799aa339c 100644
--- a/code/modules/mob/living/carbon/human/human.dm
+++ b/code/modules/mob/living/carbon/human/human.dm
@@ -2,7 +2,6 @@
name = "Unknown"
real_name = "Unknown"
icon = 'icons/mob/human.dmi'
- icon_state = "human_basic"
appearance_flags = KEEP_TOGETHER|TILE_BOUND|PIXEL_SCALE
COOLDOWN_DECLARE(special_emote_cooldown)
@@ -1101,24 +1100,6 @@
src.apply_damage(power, BRUTE, def_zone = pick(BODY_ZONE_PRECISE_R_FOOT, BODY_ZONE_PRECISE_L_FOOT))
src.Paralyze(10 * power)
-/mob/living/carbon/human/proc/copy_features(var/datum/character_save/CS)
- dna.features = CS.features
- gender = CS.gender
- age = CS.age
- underwear = CS.underwear
- underwear_color = CS.underwear_color
- undershirt = CS.undershirt
- socks = CS.socks
- hair_style = CS.hair_style
- hair_color = CS.hair_color
- gradient_color = CS.gradient_color
- gradient_style = CS.gradient_style
- facial_hair_style = CS.facial_hair_style
- facial_hair_color = CS.facial_hair_color
- skin_tone = CS.skin_tone
- eye_color = CS.eye_color
- updateappearance(TRUE, TRUE, TRUE)
-
/mob/living/carbon/human/monkeybrain
ai_controller = /datum/ai_controller/monkey
diff --git a/code/modules/mob/living/carbon/human/human_helpers.dm b/code/modules/mob/living/carbon/human/human_helpers.dm
index f96afce2e4860..29912fdf64252 100644
--- a/code/modules/mob/living/carbon/human/human_helpers.dm
+++ b/code/modules/mob/living/carbon/human/human_helpers.dm
@@ -288,3 +288,23 @@
return TRUE
if(isclothing(wear_mask) && (wear_mask.clothing_flags & SCAN_BOOZEPOWER))
return TRUE
+
+///copies over clothing preferences like underwear to another human
+/mob/living/carbon/human/proc/copy_clothing_prefs(mob/living/carbon/human/destination)
+ destination.underwear = underwear
+ destination.underwear_color = underwear_color
+ destination.undershirt = undershirt
+ destination.socks = socks
+ destination.jumpsuit_style = jumpsuit_style
+
+
+/// Fully randomizes everything according to the given flags.
+/mob/living/carbon/human/proc/randomize_human_appearance(randomize_flags = ALL)
+ var/datum/preferences/preferences = new
+
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (!preference.included_in_randomization_flags(randomize_flags))
+ continue
+
+ if (preference.is_randomizable())
+ preferences.write_preference(preference, preference.create_random_value(preferences))
diff --git a/code/modules/mob/living/carbon/human/species.dm b/code/modules/mob/living/carbon/human/species.dm
index f7ef72b87fc18..ca47163b6ba51 100644
--- a/code/modules/mob/living/carbon/human/species.dm
+++ b/code/modules/mob/living/carbon/human/species.dm
@@ -1,10 +1,17 @@
// This code handles different species in the game.
GLOBAL_LIST_EMPTY(roundstart_races)
+GLOBAL_LIST_EMPTY(accepatable_no_hard_check_races)
+
+/// An assoc list of species types to their features (from get_features())
+GLOBAL_LIST_EMPTY(features_by_species)
/datum/species
var/id // if the game needs to manually check your race to do something not included in a proc here, it will use this
var/name // this is the fluff name. these will be left generic (such as 'Lizardperson' for the lizard race) so servers can change them to whatever
+ /// The formatting of the name of the species in plural context. Defaults to "[name]\s" if unset.
+ /// Ex "[Plasmamen] are weak", "[Mothmen] are strong", "[Lizardpeople] don't like", "[Golems] hate"
+ var/plural_form
var/bodyflag = FLAG_HUMAN //Species flags currently used for species restriction on items
var/default_color = "#FFF" // if alien colors are disabled, this is the color that will be used by that race
var/bodytype = BODYTYPE_HUMANOID
@@ -18,7 +25,8 @@ GLOBAL_LIST_EMPTY(roundstart_races)
var/digitigrade_customization = DIGITIGRADE_NEVER //Never, Optional, or Forced digi legs?
var/use_skintones = FALSE // does it use skintones or not? (spoiler alert this is only used by humans)
- var/exotic_blood = "" // If your race wants to bleed something other than bog standard blood, change this to reagent id.
+ ///If your race bleeds something other than bog standard blood, change this to reagent id. For example, ethereals bleed liquid electricity.
+ var/datum/reagent/exotic_blood
var/exotic_bloodtype = "" //If your race uses a non standard bloodtype (A+, O-, AB-, etc)
var/meat = /obj/item/reagent_containers/food/snacks/meat/slab/human //What the species drops on gibbing
var/skinned_type
@@ -107,15 +115,67 @@ GLOBAL_LIST_EMPTY(roundstart_races)
// PROCS //
///////////
+/datum/species/New()
+ if(!plural_form)
+ plural_form = "[name]\s"
+ return ..()
+
+/// Gets a list of all species available to choose in roundstart.
+/proc/get_selectable_species()
+ RETURN_TYPE(/list)
+
+ if (!GLOB.roundstart_races.len)
+ GLOB.roundstart_races = generate_selectable_species()
+
+ return GLOB.roundstart_races
/proc/generate_selectable_species()
- for(var/I in subtypesof(/datum/species))
- var/datum/species/S = new I
- if(S.check_roundstart_eligible())
- GLOB.roundstart_races += S.id
- qdel(S)
- if(!GLOB.roundstart_races.len)
- GLOB.roundstart_races += "human"
+ var/list/selectable_species = list()
+
+ for(var/species_type in subtypesof(/datum/species))
+ var/datum/species/species = new species_type
+ if(species.check_roundstart_eligible())
+ selectable_species += species.id
+ qdel(species)
+
+ if(!selectable_species.len)
+ selectable_species += get_fallback_species_id()
+
+ return selectable_species
+
+/proc/get_fallback_species_id()
+ var/fallback = CONFIG_GET(string/fallback_default_species)
+ var/id = fallback
+ if(fallback == "random") // absolute schizoposting
+ if(length(GLOB.roundstart_races))
+ id = pick(GLOB.roundstart_races)
+ else
+ var/datum/species/type = pick(subtypesof(/datum/species))
+ id = initial(type.id)
+ return id
+
+/// Gets a list of species that are allowed to be used from the DB even if they are disabled due to roundstart_no_hard_check
+/// Use get_selectable_species() for new/editing characters.
+/proc/get_acceptable_species()
+ RETURN_TYPE(/list)
+
+ if (!GLOB.accepatable_no_hard_check_races.len)
+ GLOB.accepatable_no_hard_check_races = generate_acceptable_species()
+
+ return GLOB.accepatable_no_hard_check_races
+
+/proc/generate_acceptable_species()
+ var/list/base = get_selectable_species() // normally allowed species.
+ var/list/no_hard_check = CONFIG_GET(keyed_list/roundstart_no_hard_check)
+ no_hard_check = no_hard_check.Copy()
+ for(var/species_id in no_hard_check)
+ if(!GLOB.species_list[species_id])
+ continue
+ base += species_id
+ no_hard_check -= species_id
+ for(var/species_id in no_hard_check) // warn any invalid species in the config.
+ stack_trace("WARNING: roundstart_no_hard_check contains invalid species ID: [species_id]")
+ return base
/datum/species/proc/check_roundstart_eligible()
if(id in (CONFIG_GET(keyed_list/roundstart_races)))
@@ -367,7 +427,7 @@ GLOBAL_LIST_EMPTY(roundstart_races)
if(istype(I))
C.dropItemToGround(I)
else //Entries in the list should only ever be items or null, so if it's not an item, we can assume it's an empty hand
- C.put_in_hands(new mutanthands())
+ INVOKE_ASYNC(C, /mob/proc/put_in_hands, new mutanthands) // async due to prefs UI calling this and using SHOULD_NOT_SLEEP
if(NOMOUTH in species_traits)
for(var/obj/item/bodypart/head/head in C.bodyparts)
@@ -2227,6 +2287,467 @@ GLOBAL_LIST_EMPTY(roundstart_races)
/datum/species/proc/get_huff_sound(mob/living/carbon/user)
return
-//generic action proc for keybind stuff
+/// Returns a list of strings representing features this species has.
+/// Used by the preferences UI to know what buttons to show.
+/// Should only need to override if the feature is not attached to a mutant bodypart or trait
+/datum/species/proc/get_features()
+ var/cached_features = GLOB.features_by_species[type]
+ if (!isnull(cached_features))
+ return cached_features
+
+ var/list/features = list()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+
+ if ( \
+ (preference.relevant_mutant_bodypart in mutant_bodyparts) \
+ || (preference.relevant_species_trait in species_traits) \
+ )
+ features += preference.db_key
+
+ GLOB.features_by_species[type] = features
+
+ return features
+
+/// Given a human, will adjust it before taking a picture for the preferences UI.
+/// This should create a CONSISTENT result, so the icons don't randomly change.
+/datum/species/proc/prepare_human_for_preview(mob/living/carbon/human/human)
+ return
+
+/**
+ * Gets a short description for the specices. Should be relatively succinct.
+ * Used in the preference menu.
+ *
+ * Returns a string.
+ */
+/datum/species/proc/get_species_description()
+ SHOULD_CALL_PARENT(FALSE)
+
+ stack_trace("Species [name] ([type]) did not have a description set, and is a selectable roundstart race! Override get_species_description.")
+ return "No species description set, file a bug report!"
+
+/**
+ * Gets the lore behind the type of species. Can be long.
+ * Used in the preference menu.
+ *
+ * Returns a list of strings.
+ * Between each entry in the list, a newline will be inserted, for formatting.
+ */
+/datum/species/proc/get_species_lore()
+ SHOULD_CALL_PARENT(FALSE)
+ RETURN_TYPE(/list)
+
+ stack_trace("Species [name] ([type]) did not have lore set, and is a selectable roundstart race! Override get_species_lore.")
+ return list("No species lore set, file a bug report!")
+
+/**
+ * Translate the species liked foods from bitfields into strings
+ * and returns it in the form of an associated list.
+ *
+ * Returns a list, or null if they have no diet.
+ */
+/datum/species/proc/get_species_diet()
+ if(TRAIT_NOHUNGER in inherent_traits)
+ return null
+
+ var/list/food_flags = FOOD_FLAGS
+
+ return list(
+ "liked_food" = bitfield_to_list(initial(mutanttongue.liked_food), food_flags),
+ "disliked_food" = bitfield_to_list(initial(mutanttongue.disliked_food), food_flags),
+ "toxic_food" = bitfield_to_list(initial(mutanttongue.toxic_food), food_flags),
+ )
+
+/**
+ * Generates a list of "perks" related to this species
+ * (Postives, neutrals, and negatives)
+ * in the format of a list of lists.
+ * Used in the preference menu.
+ *
+ * "Perk" format is as followed:
+ * list(
+ * SPECIES_PERK_TYPE = type of perk (postiive, negative, neutral - use the defines)
+ * SPECIES_PERK_ICON = icon shown within the UI
+ * SPECIES_PERK_NAME = name of the perk on hover
+ * SPECIES_PERK_DESC = description of the perk on hover
+ * )
+ *
+ * Returns a list of lists.
+ * The outer list is an assoc list of [perk type]s to a list of perks.
+ * The innter list is a list of perks. Can be empty, but won't be null.
+ */
+/datum/species/proc/get_species_perks()
+ var/list/species_perks = list()
+
+ // Let us get every perk we can concieve of in one big list.
+ // The order these are called (kind of) matters.
+ // Species unique perks first, as they're more important than genetic perks,
+ // and language perk last, as it comes at the end of the perks list
+ species_perks += create_pref_unique_perks()
+ species_perks += create_pref_blood_perks()
+ species_perks += create_pref_combat_perks()
+ species_perks += create_pref_damage_perks()
+ species_perks += create_pref_temperature_perks()
+ species_perks += create_pref_traits_perks()
+ species_perks += create_pref_biotypes_perks()
+ species_perks += create_pref_language_perk()
+
+ // Some overrides may return `null`, prevent those from jamming up the list.
+ list_clear_nulls(species_perks)
+
+ // Now let's sort them out for cleanliness and sanity
+ var/list/perks_to_return = list(
+ SPECIES_POSITIVE_PERK = list(),
+ SPECIES_NEUTRAL_PERK = list(),
+ SPECIES_NEGATIVE_PERK = list(),
+ )
+
+ for(var/list/perk as anything in species_perks)
+ var/perk_type = perk[SPECIES_PERK_TYPE]
+ // If we find a perk that isn't postiive, negative, or neutral,
+ // it's a bad entry - don't add it to our list. Throw a stack trace and skip it instead.
+ if(isnull(perks_to_return[perk_type]))
+ stack_trace("Invalid species perk ([perk[SPECIES_PERK_NAME]]) found for species [name]. \
+ The type should be positive, negative, or neutral. (Got: [perk_type])")
+ continue
+
+ perks_to_return[perk_type] += list(perk)
+
+ return perks_to_return
+
+/**
+ * Used to add any species specific perks to the perk list.
+ *
+ * Returns null by default. When overriding, return a list of perks.
+ */
+/datum/species/proc/create_pref_unique_perks()
+ return null
+
+/**
+ * Adds adds any perks related to combat.
+ * For example, the damage type of their punches.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_combat_perks()
+ var/list/to_add = list()
+
+ if(attack_type != BRUTE)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "fist-raised",
+ SPECIES_PERK_NAME = "Elemental Attacker",
+ SPECIES_PERK_DESC = "[plural_form] deal [attack_type] damage with their punches instead of brute.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to sustaining damage.
+ * For example, brute damage vulnerability, or fire damage resistance.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_damage_perks()
+ var/list/to_add = list()
+
+ // Brute related
+ if(brutemod > 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "band-aid",
+ SPECIES_PERK_NAME = "Brutal Weakness",
+ SPECIES_PERK_DESC = "[plural_form] are weak to brute damage.",
+ ))
+ else if(brutemod < 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "shield-alt",
+ SPECIES_PERK_NAME = "Brutal Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to bruising and brute damage.",
+ ))
+
+ // Burn related
+ if(burnmod > 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "burn",
+ SPECIES_PERK_NAME = "Fire Weakness",
+ SPECIES_PERK_DESC = "[plural_form] are weak to fire and burn damage.",
+ ))
+ else if(burnmod < 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "shield-alt",
+ SPECIES_PERK_NAME = "Fire Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to flames, and burn damage.",
+ ))
+
+ if(TRAIT_SHOCKIMMUNE in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bolt",
+ SPECIES_PERK_NAME = "Shock Immune",
+ SPECIES_PERK_DESC = "[plural_form] are entirely resistant to electrical shocks.",
+ ))
+ else if(siemens_coeff > 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "bolt",
+ SPECIES_PERK_NAME = "Shock Vulnerability",
+ SPECIES_PERK_DESC = "[plural_form] are vulnerable to being shocked.",
+ ))
+ else if(siemens_coeff < 1)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "shield-alt",
+ SPECIES_PERK_NAME = "Shock Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to being shocked.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to how the species deals with temperature.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_temperature_perks()
+ var/list/to_add = list()
+
+ // Hot temperature tolerance
+ if(heatmod > 1/* || bodytemp_heat_damage_limit < BODYTEMP_HEAT_DAMAGE_LIMIT*/)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "temperature-high",
+ SPECIES_PERK_NAME = "Heat Vulnerability",
+ SPECIES_PERK_DESC = "[plural_form] are vulnerable to high temperatures.",
+ ))
+
+ if(heatmod < 1/* || bodytemp_heat_damage_limit > BODYTEMP_HEAT_DAMAGE_LIMIT*/)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "thermometer-empty",
+ SPECIES_PERK_NAME = "Heat Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to hotter environments.",
+ ))
+
+ // Cold temperature tolerance
+ if(coldmod > 1/* || bodytemp_cold_damage_limit > BODYTEMP_COLD_DAMAGE_LIMIT*/)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "temperature-low",
+ SPECIES_PERK_NAME = "Cold Vulnerability",
+ SPECIES_PERK_DESC = "[plural_form] are vulnerable to cold temperatures.",
+ ))
+
+ if(coldmod < 1/* || bodytemp_cold_damage_limit < BODYTEMP_COLD_DAMAGE_LIMIT*/)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "thermometer-empty",
+ SPECIES_PERK_NAME = "Cold Resilience",
+ SPECIES_PERK_DESC = "[plural_form] are resilient to colder environments.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to the species' blood (or lack thereof).
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_blood_perks()
+ var/list/to_add = list()
+
+ // NOBLOOD takes priority by default
+ if(NOBLOOD in species_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "tint-slash",
+ SPECIES_PERK_NAME = "Bloodletted",
+ SPECIES_PERK_DESC = "[plural_form] do not have blood.",
+ ))
+
+ // Otherwise, check if their exotic blood is a valid typepath
+ else if(ispath(exotic_blood))
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "tint",
+ SPECIES_PERK_NAME = initial(exotic_blood.name),
+ SPECIES_PERK_DESC = "[name] blood is [initial(exotic_blood.name)], which can make recieving medical treatment harder.",
+ ))
+
+ // Otherwise otherwise, see if they have an exotic bloodtype set
+ else if(exotic_bloodtype)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "tint",
+ SPECIES_PERK_NAME = "Exotic Blood",
+ SPECIES_PERK_DESC = "[plural_form] have \"[exotic_bloodtype]\" type blood, which can make recieving medical treatment harder.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to the species' inherent_traits list.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_traits_perks()
+ var/list/to_add = list()
+
+ if(TRAIT_LIMBATTACHMENT in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "user-plus",
+ SPECIES_PERK_NAME = "Limbs Easily Reattached",
+ SPECIES_PERK_DESC = "[plural_form] limbs are easily reattached, and as such do not \
+ require surgery to restore. Simply pick it up and pop it back in, champ!",
+ ))
+
+ if(TRAIT_EASYDISMEMBER in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "user-times",
+ SPECIES_PERK_NAME = "Limbs Easily Dismembered",
+ SPECIES_PERK_DESC = "[plural_form] limbs are not secured well, and as such they are easily dismembered.",
+ ))
+
+ if(TRAIT_NODISMEMBER in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "user-shield",
+ SPECIES_PERK_NAME = "Well-Attached Limbs",
+ SPECIES_PERK_DESC = "[plural_form] cannot be dismembered.",
+ ))
+
+ if(TRAIT_TOXINLOVER in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "syringe",
+ SPECIES_PERK_NAME = "Toxins Lover",
+ SPECIES_PERK_DESC = "Toxins damage dealt to [plural_form] are reversed - healing toxins will instead cause harm, and \
+ causing toxins will instead cause healing. Be careful around purging chemicals!",
+ ))
+
+ if(TRAIT_NOFIRE in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "fire-extinguisher",
+ SPECIES_PERK_NAME = "Fireproof",
+ SPECIES_PERK_DESC = "[plural_form] are entirely immune to catching fire.",
+ ))
+
+ if(TRAIT_NOHUNGER in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "utensils",
+ SPECIES_PERK_NAME = "No Hunger",
+ SPECIES_PERK_DESC = "[plural_form] are never hungry.",
+ ))
+
+ if(TRAIT_RADIMMUNE in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "radiation",
+ SPECIES_PERK_NAME = "Radiation Immune",
+ SPECIES_PERK_DESC = "[plural_form] are entirely immune to radiation.",
+ ))
+
+ if(TRAIT_RESISTHIGHPRESSURE in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "wind",
+ SPECIES_PERK_NAME = "High-Pressure Resistance",
+ SPECIES_PERK_DESC = "[plural_form] are resistant to high atmospheric pressures.",
+ ))
+
+ if(TRAIT_RESISTLOWPRESSURE in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "level-down-alt",
+ SPECIES_PERK_NAME = "Low-Pressure Resistance",
+ SPECIES_PERK_DESC = "[plural_form] are resistant to low atmospheric pressures.",
+ ))
+
+ if(TRAIT_TOXIMMUNE in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "biohazard",
+ SPECIES_PERK_NAME = "Toxin Immune",
+ SPECIES_PERK_DESC = "[plural_form] are immune to toxin damage.",
+ ))
+
+ if(TRAIT_PIERCEIMMUNE in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "syringe",
+ SPECIES_PERK_NAME = "Tough Skin",
+ SPECIES_PERK_DESC = "[plural_form] have tough skin, blocking piercing and embedding of sharp objects, including needles.",
+ ))
+
+ if(TRAIT_POWERHUNGRY in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bolt",
+ SPECIES_PERK_NAME = "Shockingly Tasty",
+ SPECIES_PERK_DESC = "Ethereals can feed on electricity from APCs, powercells, and lights; and do not otherwise need to eat.",
+ ))
+
+ return to_add
+
+/**
+ * Adds adds any perks related to the species' inherent_biotypes flags.
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ if(MOB_UNDEAD in inherent_biotypes)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "skull",
+ SPECIES_PERK_NAME = "Undead",
+ SPECIES_PERK_DESC = "[plural_form] are of the undead! The undead do not have the need to eat or breathe, and \
+ most viruses will not be able to infect a walking corpse. Their worries mostly stop at remaining in one piece, really.",
+ ))
+
+ return to_add
+
+/**
+ * Adds in a language perk based on all the languages the species
+ * can speak by default (according to their language holder).
+ *
+ * Returns a list containing perks, or an empty list.
+ */
+/datum/species/proc/create_pref_language_perk()
+ var/list/to_add = list()
+
+ // Grab galactic common as a path, for comparisons
+ var/datum/language/common_language = /datum/language/common
+
+ // Now let's find all the languages they can speak that aren't common
+ var/list/bonus_languages = list()
+ var/datum/language_holder/temp_holder = new species_language_holder()
+ for(var/datum/language/language_type as anything in temp_holder.spoken_languages)
+ if(ispath(language_type, common_language))
+ continue
+ bonus_languages += initial(language_type.name)
+
+ // If we have any languages we can speak: create a perk for them all
+ if(length(bonus_languages))
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "comment",
+ SPECIES_PERK_NAME = "Native Speaker",
+ SPECIES_PERK_DESC = "Alongside [initial(common_language.name)], [plural_form] gain the ability to speak [english_list(bonus_languages)].",
+ ))
+
+ qdel(temp_holder)
+
+ return to_add
+
/datum/species/proc/primary_species_action()
return
diff --git a/code/modules/mob/living/carbon/human/species_types/IPC.dm b/code/modules/mob/living/carbon/human/species_types/IPC.dm
index 38c426920d0b3..726367170abca 100644
--- a/code/modules/mob/living/carbon/human/species_types/IPC.dm
+++ b/code/modules/mob/living/carbon/human/species_types/IPC.dm
@@ -1,5 +1,6 @@
/datum/species/ipc
name = "\improper Integrated Positronic Chassis"
+ plural_form = "IPCs"
id = SPECIES_IPC
bodyflag = FLAG_IPC
sexes = FALSE
@@ -15,7 +16,7 @@
mutant_heart = /obj/item/organ/heart/cybernetic/ipc
mutant_organs = list(/obj/item/organ/cyberimp/arm/power_cord)
mutant_bodyparts = list("ipc_screen", "ipc_antenna", "ipc_chassis")
- default_features = list("mcolor" = "#7D7D7D", "ipc_screen" = "Static", "ipc_antenna" = "None", "ipc_chassis" = "Morpheus Cyberkinetics(Greyscale)")
+ default_features = list("mcolor" = "#7D7D7D", "ipc_screen" = "Static", "ipc_antenna" = "None", "ipc_chassis" = "Morpheus Cyberkinetics (Custom)")
meat = /obj/item/stack/sheet/plasteel{amount = 5}
skinned_type = /obj/item/stack/sheet/iron{amount = 10}
exotic_blood = /datum/reagent/oil
@@ -253,3 +254,31 @@
BP.limb_id = chassis_of_choice.limbs_id
BP.name = "\improper[chassis_of_choice.name] [parse_zone(BP.body_zone)]"
BP.update_limb()
+
+/datum/species/ipc/get_species_description()
+ return "The newest in artificial life, IPCs are entirely robotic, synthetic life, made of motors, circuits, and wires \
+ - based on newly developed Postronic brain technology."
+
+/datum/species/ipc/get_species_lore()
+ return null
+
+/datum/species/ipc/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "robot",
+ SPECIES_PERK_NAME = "Robotic",
+ SPECIES_PERK_DESC = "IPCs have an entirely robotic body, meaning medical care is typically done through Robotics or Engineering. \
+ Whether this is helpful or not is heavily dependent on your coworkers. It does, however, mean you are usually able to perform self-repairs easily.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "magnet",
+ SPECIES_PERK_NAME = "EMP Vulnerable",
+ SPECIES_PERK_DESC = "IPC organs are cybernetic, and thus susceptible to electromagnetic interference. Getting hit by an EMP may stop your heart.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/abductors.dm b/code/modules/mob/living/carbon/human/species_types/abductors.dm
index c56b0fec84240..7191c9bb0aa33 100644
--- a/code/modules/mob/living/carbon/human/species_types/abductors.dm
+++ b/code/modules/mob/living/carbon/human/species_types/abductors.dm
@@ -23,3 +23,23 @@
. = ..()
var/datum/atom_hud/abductor_hud = GLOB.huds[DATA_HUD_ABDUCTOR]
abductor_hud.remove_hud_from(C)
+
+/datum/species/abductor/get_species_description()
+ return "Silent, but deadly. It's not known where they really come from, but they seem to have shown up regardless."
+
+/datum/species/abductor/get_species_lore()
+ return null
+
+/datum/species/abductor/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "volume-mute",
+ SPECIES_PERK_NAME = "Mute",
+ SPECIES_PERK_DESC = "Abductors can't speak. At all. This may upset your coworkers.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/apid.dm b/code/modules/mob/living/carbon/human/species_types/apid.dm
index 8cdc4e4422f6b..b32c86fdb14ab 100644
--- a/code/modules/mob/living/carbon/human/species_types/apid.dm
+++ b/code/modules/mob/living/carbon/human/species_types/apid.dm
@@ -87,3 +87,47 @@
/datum/species/apid/on_species_loss(mob/living/carbon/human/C, datum/species/new_species, pref_load)
C.mind?.forget_crafting_recipe(/datum/crafting_recipe/honeycomb)
return ..()
+
+/datum/species/apid/get_species_description()
+ return "Beepeople, god damn it. It's hip, and alive! Buzz buzz!"
+
+/datum/species/apid/get_species_lore()
+ return null
+
+/datum/species/apid/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bug",
+ SPECIES_PERK_NAME = "Hive-Friend",
+ SPECIES_PERK_DESC = "Apids are naturally friends with bees, and can make honeycombs!",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "level-down-alt",
+ SPECIES_PERK_NAME = "Low Air Requirements",
+ SPECIES_PERK_DESC = "Apids can breathe in lower air pressures just fine!",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "wind",
+ SPECIES_PERK_NAME = "Dashing!",
+ SPECIES_PERK_DESC = "Apids can use their wings to quickly dash forward in a flurry of buzzing!",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "icicles",
+ SPECIES_PERK_NAME = "Cold-Sensitive Biology",
+ SPECIES_PERK_DESC = "The cold makes Apids sleepy, as does smoke...",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fist-raised",
+ SPECIES_PERK_NAME = "Insectoid Biology",
+ SPECIES_PERK_DESC = "Fly swatters will deal significantly higher amounts of damage to Apids.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/dullahan.dm b/code/modules/mob/living/carbon/human/species_types/dullahan.dm
index 15a73a94ca579..e7222cbe951ef 100644
--- a/code/modules/mob/living/carbon/human/species_types/dullahan.dm
+++ b/code/modules/mob/living/carbon/human/species_types/dullahan.dm
@@ -21,7 +21,7 @@
/datum/species/dullahan/check_roundstart_eligible()
if(SSevents.holidays && SSevents.holidays[HALLOWEEN])
return TRUE
- return FALSE
+ return ..()
/datum/species/dullahan/on_species_gain(mob/living/carbon/human/H, datum/species/old_species)
. = ..()
@@ -62,6 +62,49 @@
else
H.reset_perspective(myhead)
+
+/datum/species/dullahan/get_species_description()
+ return "An angry spirit, hanging onto the land of the living for \
+ unfinished business. Or that's what the books say. They're quite nice \
+ when you get to know them."
+
+/datum/species/dullahan/get_species_lore()
+ return list(
+ "\"No wonder they're all so grumpy! Their hands are always full! I used to think, \
+ \"Wouldn't this be cool?\" but after watching these creatures suffer from their head \
+ getting dunked down disposals for the nth time, I think I'm good.\" - Captain Larry Dodd"
+ )
+
+/datum/species/dullahan/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "horse-head",
+ SPECIES_PERK_NAME = "Headless and Horseless",
+ SPECIES_PERK_DESC = "Dullahans must lug their head around in their arms. While \
+ many creative uses can come out of your head being independent of your \
+ body, Dullahans will find it mostly a pain.",
+ ))
+
+ return to_add
+
+// There isn't a "Minor Undead" biotype, so we have to explain it in an override (see: vampires)
+/datum/species/dullahan/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "skull",
+ SPECIES_PERK_NAME = "Minor Undead",
+ SPECIES_PERK_DESC = "[name] are minor undead. \
+ Minor undead enjoy some of the perks of being dead, like \
+ not needing to breathe or eat, but do not get many of the \
+ environmental immunities involved with being fully undead.",
+ ))
+
+ return to_add
+
/obj/item/organ/brain/dullahan
decoy_override = TRUE
organ_flags = 0
diff --git a/code/modules/mob/living/carbon/human/species_types/ethereal.dm b/code/modules/mob/living/carbon/human/species_types/ethereal.dm
index 113554872b820..a261fab61f0e9 100644
--- a/code/modules/mob/living/carbon/human/species_types/ethereal.dm
+++ b/code/modules/mob/living/carbon/human/species_types/ethereal.dm
@@ -184,3 +184,36 @@
/datum/species/ethereal/get_sniff_sound(mob/living/carbon/user)
return SPECIES_DEFAULT_SNIFF_SOUND(user)
+
+/datum/species/ethereal/get_features()
+ var/list/features = ..()
+
+ features += "feature_ethcolor"
+
+ return features
+
+/datum/species/ethereal/get_species_description()
+ return "Ethereals are a unique species with liquid electricity for blood and a glowing body. They thrive on electricity, and are naturally agender."
+
+/datum/species/ethereal/get_species_lore()
+ return null
+
+/datum/species/ethereal/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "lightbulb",
+ SPECIES_PERK_NAME = "Disco Ball",
+ SPECIES_PERK_DESC = "Ethereals passively generate their own light.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "biohazard",
+ SPECIES_PERK_NAME = "Starving Artist",
+ SPECIES_PERK_DESC = "Ethereals take toxin damage while starving.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/felinid.dm b/code/modules/mob/living/carbon/human/species_types/felinid.dm
index 64b38f6a978bc..5b0dcf55c7453 100644
--- a/code/modules/mob/living/carbon/human/species_types/felinid.dm
+++ b/code/modules/mob/living/carbon/human/species_types/felinid.dm
@@ -40,12 +40,12 @@
H.dna.features["ears"] = "Cat"
if(H.dna.features["ears"] == "Cat")
var/obj/item/organ/ears/cat/ears = new
- ears.Insert(H, drop_if_replaced = FALSE)
+ ears.Insert(H, drop_if_replaced = FALSE, pref_load = pref_load)
else
mutantears = /obj/item/organ/ears
if(H.dna.features["tail_human"] == "Cat")
var/obj/item/organ/tail/cat/tail = new
- tail.Insert(H, drop_if_replaced = FALSE)
+ tail.Insert(H, drop_if_replaced = FALSE, pref_load = pref_load)
else
mutanttail = null
return ..()
@@ -64,7 +64,7 @@
if(!new_ears)
// Go with default ears
new_ears = new /obj/item/organ/ears
- new_ears.Insert(H, drop_if_replaced = FALSE)
+ new_ears.Insert(H, drop_if_replaced = FALSE, pref_load = pref_load)
if(tail)
var/obj/item/organ/tail/new_tail
@@ -74,9 +74,9 @@
if(new_species.mutanttail)
new_tail = new new_species.mutanttail
if(new_tail)
- new_tail.Insert(H, drop_if_replaced = FALSE)
+ new_tail.Insert(H, drop_if_replaced = FALSE, pref_load = pref_load)
else
- tail.Remove(H)
+ tail.Remove(H, pref_load = pref_load)
/datum/species/human/felinid/handle_chemicals(datum/reagent/chem, mob/living/carbon/human/M)
if(istype(chem, /datum/reagent/consumable/cocoa))
@@ -171,3 +171,60 @@
if(!silent)
to_chat(H, "You are no longer a cat.")
+
+/datum/species/human/felinid/prepare_human_for_preview(mob/living/carbon/human/human)
+ human.hair_style = "Hime Cut"
+ human.hair_color = "fcc" // pink
+ human.update_hair()
+
+ var/obj/item/organ/ears/cat/cat_ears = human.getorgan(/obj/item/organ/ears/cat)
+ if (cat_ears)
+ cat_ears.color = human.hair_color
+ human.update_body()
+
+/datum/species/human/felinid/get_species_description()
+ return "Felinids are one of the many types of bespoke genetic \
+ modifications to come of humanity's mastery of genetic science, and are \
+ also one of the most common. Meow?"
+
+/datum/species/human/felinid/get_species_lore()
+ return list(
+ "Bio-engineering at its felinest, Felinids are the peak example of humanity's mastery of genetic code. \
+ One of many \"Animalid\" variants, Felinids are the most popular and common, as well as one of the \
+ biggest points of contention in genetic-modification.",
+
+ "Body modders were eager to splice human and feline DNA in search of the holy trifecta: ears, eyes, and tail. \
+ These traits were in high demand, with the corresponding side effects of vocal and neurochemical changes being seen as a minor inconvenience.",
+
+ "Sadly for the Felinids, they were not minor inconveniences. Shunned as subhuman and monstrous by many, Felinids (and other Animalids) \
+ sought their greener pastures out in the colonies, cloistering in communities of their own kind. \
+ As a result, outer Human space has a high Animalid population.",
+ )
+
+// Felinids are subtypes of humans.
+// This shouldn't call parent or we'll get a buncha human related perks (though it doesn't have a reason to).
+/datum/species/human/felinid/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "angle-double-down",
+ SPECIES_PERK_NAME = "Always Land On Your Feet",
+ SPECIES_PERK_DESC = "Felinids always land on their feet, and take reduced damage from falling.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "shoe-prints",
+ SPECIES_PERK_NAME = "Laser Affinity",
+ SPECIES_PERK_DESC = "Felinids can't resist the temptation of a good laser pointer, and might involuntarily chase a strong one.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "swimming-pool",
+ SPECIES_PERK_NAME = "Hydrophobia",
+ SPECIES_PERK_DESC = "Felinids don't like water, and hate going in the pool.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/flypeople.dm b/code/modules/mob/living/carbon/human/species_types/flypeople.dm
index 4b3418d57f727..65bca74b4973b 100644
--- a/code/modules/mob/living/carbon/human/species_types/flypeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/flypeople.dm
@@ -1,5 +1,6 @@
/datum/species/fly
name = "\improper Flyperson"
+ plural_form = "Flypeople"
id = SPECIES_FLY
bodyflag = FLAG_FLY
species_traits = list(NOEYESPRITES, NO_UNDERWEAR, TRAIT_BEEFRIEND)
@@ -9,7 +10,7 @@
mutantstomach = /obj/item/organ/stomach/fly
meat = /obj/item/reagent_containers/food/snacks/meat/slab/human/mutant/fly
mutant_bodyparts = list("insect_type")
- default_features = list("insect_type" = "housefly", "body_size" = "Normal")
+ default_features = list("insect_type" = "fly", "body_size" = "Normal")
burnmod = 1.4
brutemod = 1.4
speedmod = 0.7
@@ -39,7 +40,67 @@
return TRUE
return ..()
+/datum/species/fly/replace_body(mob/living/carbon/C, datum/species/new_species)
+ ..()
+
+ var/datum/sprite_accessory/insect_type/type_selection = GLOB.insect_type_list[C.dna.features["insect_type"]]
+ if(!istype(type_selection))
+ return
+
+ for(var/obj/item/bodypart/BP as() in C.bodyparts) //Override bodypart data as necessary
+ BP.uses_mutcolor = !!type_selection.color_src
+ if(BP.uses_mutcolor)
+ BP.should_draw_greyscale = TRUE
+ BP.species_color = C.dna?.features["mcolor"]
+ // Hardcoded bullshit that will probably break. Woo shitcode. Bee insect_type has dimorphic parts while flies do not.
+ BP.is_dimorphic = type_selection.gender_specific && (istype(BP, /obj/item/bodypart/head) || istype(BP, /obj/item/bodypart/chest))
+
+ BP.limb_id = type_selection.limbs_id
+ BP.name = "\improper[type_selection.name] [parse_zone(BP.body_zone)]"
+ BP.update_limb()
+
/datum/species/fly/check_species_weakness(obj/item/weapon, mob/living/attacker)
if(istype(weapon, /obj/item/melee/flyswatter))
return 29 //Flyswatters deal 30x damage to flypeople.
return 0
+
+/datum/species/fly/get_species_description()
+ return "With no official documentation or knowledge of the origin of \
+ this species, they remain a mystery to most. Any and all rumours among \
+ Nanotrasen staff regarding flypeople are often quickly silenced by high \
+ ranking staff or officials."
+
+/datum/species/fly/get_species_lore()
+ return list(
+ "Flypeople are a curious species with a striking resemblance to the insect order of Diptera, \
+ commonly known as flies. With no publically known origin, flypeople are rumored to be a side effect of bluespace travel, \
+ despite statements from Nanotrasen officials.",
+
+ "Little is known about the origins of this race, \
+ however they posess the ability to communicate with giant spiders, originally discovered in the Australicus sector \
+ and now a common occurence in black markets as a result of a breakthrough in syndicate bioweapon research.",
+
+ "Flypeople are often feared or avoided among other species, their appearance often described as unclean or frightening in some cases, \
+ and their eating habits even more so with an insufferable accent to top it off.",
+ )
+
+/datum/species/fly/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "grin-tongue",
+ SPECIES_PERK_NAME = "Uncanny Digestive System",
+ SPECIES_PERK_DESC = "Flypeople regurgitate their stomach contents and drink it \
+ off the floor to eat and drink with little care for taste, favoring gross foods.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fist-raised",
+ SPECIES_PERK_NAME = "Insectoid Biology",
+ SPECIES_PERK_DESC = "Fly swatters will deal significantly higher amounts of damage to a Flyperson.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/golems.dm b/code/modules/mob/living/carbon/human/species_types/golems.dm
index 59d0aa2c1cd97..3ea22e48eeab2 100644
--- a/code/modules/mob/living/carbon/human/species_types/golems.dm
+++ b/code/modules/mob/living/carbon/human/species_types/golems.dm
@@ -50,6 +50,21 @@
var/golem_name = "[prefix] [golem_surname]"
return golem_name
+/datum/species/golem/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "gem",
+ SPECIES_PERK_NAME = "Lithoid",
+ SPECIES_PERK_DESC = "Lithoids are creatures made out of elements instead of \
+ blood and flesh. Because of this, they're generally stronger, slower, \
+ and mostly immune to environmental dangers and dangers to their health, \
+ such as viruses and dismemberment.",
+ ))
+
+ return to_add
+
/datum/species/golem/random
name = "Random golem"
changesource_flags = MIRROR_BADMIN | WABBAJACK | MIRROR_PRIDE | MIRROR_MAGIC | RACE_SWAP | ERT_SPAWN
@@ -790,6 +805,48 @@
new /obj/structure/cloth_pile(get_turf(H), H)
..()
+/datum/species/golem/cloth/get_species_description()
+ return "A wrapped up Mummy! They descend upon Space Station Thirteen every year to spook the crew! \"Return the slab!\""
+
+/datum/species/golem/cloth/get_species_lore()
+ return list(
+ "Mummies are very self conscious. They're shaped weird, they walk slow, and worst of all, \
+ they're considered the laziest halloween costume. But that's not even true, they say.",
+
+ "Making a mummy costume may be easy, but making a CONVINCING mummy costume requires \
+ things like proper fabric and purposeful staining to achieve the look. Which is FAR from easy. Gosh.",
+ )
+
+// Calls parent, as Golems have a species-wide perk we care about.
+/datum/species/golem/cloth/create_pref_unique_perks()
+ var/list/to_add = ..()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "recycle",
+ SPECIES_PERK_NAME = "Reformation",
+ SPECIES_PERK_DESC = "A boon quite similar to Ethereals, Mummies collapse into \
+ a pile of bandages after they die. If left alone, they will reform back \
+ into themselves. The bandages themselves are very vulnerable to fire.",
+ ))
+
+ return to_add
+
+// Override to add a perk elaborating on just how dangerous fire is.
+/datum/species/golem/cloth/create_pref_temperature_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fire-alt",
+ SPECIES_PERK_NAME = "Incredibly Flammable",
+ SPECIES_PERK_DESC = "Mummies are made entirely of cloth, which makes them \
+ very vulnerable to fire. They will not reform if they die while on \
+ fire, and they will easily catch alight. If your bandages burn to ash, you're toast!",
+ ))
+
+ return to_add
+
/obj/structure/cloth_pile
name = "pile of bandages"
desc = "It emits a strange aura, as if there was still life within it..."
diff --git a/code/modules/mob/living/carbon/human/species_types/humans.dm b/code/modules/mob/living/carbon/human/species_types/humans.dm
index cd2093443f287..1e1a45b1d25c9 100644
--- a/code/modules/mob/living/carbon/human/species_types/humans.dm
+++ b/code/modules/mob/living/carbon/human/species_types/humans.dm
@@ -42,3 +42,40 @@
/datum/species/human/get_sniff_sound(mob/living/carbon/user)
return SPECIES_DEFAULT_SNIFF_SOUND(user)
+
+/datum/species/human/prepare_human_for_preview(mob/living/carbon/human/human)
+ human.hair_style = "Business Hair"
+ human.hair_color = "b96" // brown
+ human.update_hair()
+
+/datum/species/human/get_species_description()
+ return "Humans are the dominant species in the known galaxy. \
+ Their kind extend from old Earth to the edges of known space."
+
+/datum/species/human/get_species_lore()
+ return list(
+ "These primate-descended creatures, originating from the mostly harmless Earth, \
+ have long-since outgrown their home and semi-benign designation. \
+ The space age has taken humans out of their solar system and into the galaxy-at-large."
+ )
+
+/datum/species/human/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "robot",
+ SPECIES_PERK_NAME = "Asimov Superiority",
+ SPECIES_PERK_DESC = "The AI and their cyborgs are often (but not always) subservient only \
+ to humans. As a human, silicons are required to both protect and obey you under the Asimov lawset.",
+ ))
+
+ if(CONFIG_GET(flag/enforce_human_authority))
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bullhorn",
+ SPECIES_PERK_NAME = "Chain of Command",
+ SPECIES_PERK_DESC = "Nanotrasen only recognizes humans for command roles, such as Captain.",
+ ))
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/jellypeople.dm b/code/modules/mob/living/carbon/human/species_types/jellypeople.dm
index e431680fcdeb9..11e111b41b446 100644
--- a/code/modules/mob/living/carbon/human/species_types/jellypeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/jellypeople.dm
@@ -5,6 +5,7 @@
/datum/species/oozeling/slime
name = "Slimeperson"
+ plural_form = "Slimepeople"
id = SPECIES_SLIMEPERSON
default_color = "00FFFF"
species_traits = list(MUTCOLORS,EYECOLOR,HAIR,FACEHAIR,NOBLOOD)
@@ -305,6 +306,7 @@
/datum/species/oozeling/luminescent
name = "Luminescent"
+ plural_form = null
id = SPECIES_LUMINESCENT
var/glow_intensity = LUMINESCENT_DEFAULT_GLOW
var/obj/effect/dummy/luminescent_glow/glow
@@ -486,6 +488,7 @@ GLOBAL_LIST_EMPTY(slime_links_by_mind)
/datum/species/oozeling/stargazer
name = "Stargazer"
+ plural_form = null
id = SPECIES_STARGAZER
examine_limb_id = SPECIES_OOZELING
/// The stargazer's telepathy ability.
diff --git a/code/modules/mob/living/carbon/human/species_types/lizardpeople.dm b/code/modules/mob/living/carbon/human/species_types/lizardpeople.dm
index 608cbaa2e8987..511378ab4e9c7 100644
--- a/code/modules/mob/living/carbon/human/species_types/lizardpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/lizardpeople.dm
@@ -1,6 +1,7 @@
/datum/species/lizard
// Reptilian humanoids with scaled skin and tails.
name = "\improper Lizardperson"
+ plural_form = "Lizardpeople"
id = SPECIES_LIZARD
bodyflag = FLAG_LIZARD
default_color = "00FF00"
@@ -70,6 +71,13 @@
/datum/species/lizard/get_sniff_sound(mob/living/carbon/user)
return SPECIES_DEFAULT_SNIFF_SOUND(user)
+/datum/species/lizard/get_species_description()
+ return "Lizardpeople, unlike many 'Animalid' species, are not derived from humans, and are simply bipedal reptile-like people. \
+ Lizards often find great pride in their species."
+
+/datum/species/lizard/get_species_lore()
+ return null
+
/*
Lizard subspecies: ASHWALKERS
*/
diff --git a/code/modules/mob/living/carbon/human/species_types/monkey.dm b/code/modules/mob/living/carbon/human/species_types/monkey.dm
index f438fd255d7c9..349806028e497 100644
--- a/code/modules/mob/living/carbon/human/species_types/monkey.dm
+++ b/code/modules/mob/living/carbon/human/species_types/monkey.dm
@@ -16,3 +16,59 @@
species_r_arm = /obj/item/bodypart/r_arm/monkey
species_l_leg = /obj/item/bodypart/l_leg/monkey
species_r_leg = /obj/item/bodypart/r_leg/monkey
+
+/datum/species/monkey/get_species_description()
+ return "Monkeys are a type of primate that exist between humans and animals on the evolutionary chain. \
+ Every year, on Monkey Day, Nanotrasen shows their respect for the little guys by allowing them to roam the station freely."
+
+/datum/species/monkey/get_species_lore()
+ return list(
+ "Monkeys are commonly used as test subjects on board Space Station 13. \
+ But what if... for one day... the Monkeys were allowed to be the scientists? \
+ What experiments would they come up with? Would they (stereotypically) be related to bananas somehow? \
+ There's only one way to find out.",
+ )
+
+/datum/species/monkey/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "spider",
+ SPECIES_PERK_NAME = "Vent Crawling",
+ SPECIES_PERK_DESC = "Monkeys can crawl through the vent and scrubber networks while wearing no clothing. \
+ Stay out of the kitchen!",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "paw",
+ SPECIES_PERK_NAME = "Primal Primate",
+ SPECIES_PERK_DESC = "Monkeys are primitive humans, and can't do most things a human can do. Computers are impossible, \
+ complex machines are right out, and most clothes don't fit your smaller form.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "capsules",
+ SPECIES_PERK_NAME = "Mutadone Averse",
+ SPECIES_PERK_DESC = "Monkeys are reverted into normal humans upon being exposed to Mutadone.",
+ ),
+ )
+
+ return to_add
+
+/datum/species/monkey/create_pref_language_perk()
+ var/list/to_add = list()
+ // Holding these variables so we can grab the exact names for our perk.
+ var/datum/language/common_language = /datum/language/common
+ var/datum/language/monkey_language = /datum/language/monkey
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "comment",
+ SPECIES_PERK_NAME = "Primitive Tongue",
+ SPECIES_PERK_DESC = "You may be able to understand [initial(common_language.name)], but you can't speak it. \
+ You can only speak [initial(monkey_language.name)].",
+ ))
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/mothmen.dm b/code/modules/mob/living/carbon/human/species_types/mothmen.dm
index 97060963fb669..c436da3e406ad 100644
--- a/code/modules/mob/living/carbon/human/species_types/mothmen.dm
+++ b/code/modules/mob/living/carbon/human/species_types/mothmen.dm
@@ -7,6 +7,7 @@
/datum/species/moth
name = "\improper Mothman"
+ plural_form = "Mothpeople"
id = SPECIES_MOTH
bodyflag = FLAG_MOTH
default_color = "00FF00"
@@ -196,3 +197,35 @@
#undef COCOON_HARM_AMOUNT
#undef COCOON_HEAL_AMOUNT
#undef COCOON_NUTRITION_AMOUNT
+
+/datum/species/moth/get_species_description()
+ return "Mothpeople are an intelligent species, known for their affinity to all things moth - lights, cloth, wings, and friendship."
+
+/datum/species/moth/get_species_lore()
+ return null
+
+/datum/species/moth/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "feather-alt",
+ SPECIES_PERK_NAME = "Precious Wings",
+ SPECIES_PERK_DESC = "Moths can fly in pressurized, zero-g environments and safely land short falls using their wings.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "tshirt",
+ SPECIES_PERK_NAME = "Meal Plan",
+ SPECIES_PERK_DESC = "Moths can eat clothes for nourishment.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fire",
+ SPECIES_PERK_NAME = "Ablazed Wings",
+ SPECIES_PERK_DESC = "Moth wings are fragile, and can be easily burnt off. However, moths can spin a cooccon to restore their wings if necessary.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/oozelings.dm b/code/modules/mob/living/carbon/human/species_types/oozelings.dm
index e2a71ad70432a..99eb6a57d49e0 100644
--- a/code/modules/mob/living/carbon/human/species_types/oozelings.dm
+++ b/code/modules/mob/living/carbon/human/species_types/oozelings.dm
@@ -209,3 +209,50 @@
/datum/species/oozeling/get_sniff_sound(mob/living/carbon/user)
return SPECIES_DEFAULT_SNIFF_SOUND(user)
+
+/datum/species/oozeling/get_species_description()
+ return "Literally made of jelly, Oozelings are squishy friends aboard Space Station 13."
+
+/datum/species/oozeling/get_species_lore()
+ return null
+
+/datum/species/oozeling/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "angle-double-down",
+ SPECIES_PERK_NAME = "Splat!",
+ SPECIES_PERK_DESC = "[plural_form] have special resistance to falling, because their body and organs can flatten on impact. \
+ It might hurt a bit, but generally [plural_form] can fall a lot further before their vitals organs start being pulverized.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "street-view",
+ SPECIES_PERK_NAME = "Regenerative Limbs",
+ SPECIES_PERK_DESC = "[plural_form] can regrow their limbs at will, provided they have enough Jelly.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "tint-slash",
+ SPECIES_PERK_NAME = "Hydrophobic",
+ SPECIES_PERK_DESC = "[plural_form] are decomposed by water - contact with water, water vapor, or ingesting water can lead to rapid loss of body mass.",
+ )
+ )
+
+ return to_add
+
+/datum/species/oozeling/create_pref_blood_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "tint",
+ SPECIES_PERK_NAME = "Jelly Blood",
+ SPECIES_PERK_DESC = "[plural_form] don't have blood, but instead have toxic [initial(exotic_blood.name)]! \
+ Jelly is extremely important, as losing it will cause you to cannibalize your limbs. Having low jelly will make medical treatment very difficult. \
+ Jelly is also extremely sensitive to cold, and you may rapidy solidify. [plural_form] regain jelly passively by eating, but supplemental injections are possible.",
+ ))
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/plasmamen.dm b/code/modules/mob/living/carbon/human/species_types/plasmamen.dm
index 4fe66c385e94b..f99b7833128b0 100644
--- a/code/modules/mob/living/carbon/human/species_types/plasmamen.dm
+++ b/code/modules/mob/living/carbon/human/species_types/plasmamen.dm
@@ -1,10 +1,11 @@
/datum/species/plasmaman
name = "\improper Plasmaman"
+ plural_form = "Plasmamen"
id = SPECIES_PLASMAMAN
bodyflag = FLAG_PLASMAMAN
sexes = 0
meat = /obj/item/stack/sheet/mineral/plasma
- species_traits = list(NOBLOOD,NOTRANSSTING)
+ species_traits = list(NOBLOOD,NOTRANSSTING,ENVIROSUIT)
inherent_traits = list(TRAIT_RESISTCOLD,TRAIT_RADIMMUNE,TRAIT_NOHUNGER,TRAIT_ALWAYS_CLEAN)
inherent_biotypes = list(MOB_INORGANIC, MOB_HUMANOID)
mutantlungs = /obj/item/organ/lungs/plasmaman
@@ -67,17 +68,17 @@
/datum/species/plasmaman/after_equip_job(datum/job/J, mob/living/carbon/human/H, visualsOnly = FALSE, client/preference_source = null)
H.open_internals(H.get_item_for_held_index(2))
- if(!preference_source)
+ if(!preference_source?.prefs)
return
var/path = J.species_outfits?[SPECIES_PLASMAMAN]
if (!path) //Somehow we were given a job without a plasmaman suit, use the default one so we don't go in naked!
path = /datum/outfit/plasmaman
stack_trace("Job [J] lacks a species_outfits entry for plasmamen!")
var/datum/outfit/plasmaman/O = new path
- var/datum/character_save/CS = preference_source.prefs.active_character
- if(CS.helmet_style != HELMET_DEFAULT)
- if(O.helmet_variants[CS.helmet_style])
- var/helmet = O.helmet_variants[CS.helmet_style]
+ var/selected_style = preference_source.prefs.read_character_preference(/datum/preference/choiced/helmet_style)
+ if(selected_style != HELMET_DEFAULT)
+ if(O.helmet_variants[selected_style])
+ var/helmet = O.helmet_variants[selected_style]
qdel(H.head)
H.equip_to_slot(new helmet, ITEM_SLOT_HEAD)
H.open_internals(H.get_item_for_held_index(2))
@@ -145,3 +146,67 @@
/datum/species/plasmaman/get_sniff_sound(mob/living/carbon/user)
return SPECIES_DEFAULT_SNIFF_SOUND(user)
+
+/datum/species/plasmaman/get_species_description()
+ return "Found on the Icemoon of Freyja, plasmamen consist of colonial \
+ fungal organisms which together form a sentient being. In human space, \
+ they're usually attached to skeletons to afford a human touch."
+
+/datum/species/plasmaman/get_species_lore()
+ return list(
+ "A confusing species, plasmamen are truly \"a fungus among us\". \
+ What appears to be a singular being is actually a colony of millions of organisms \
+ surrounding a found (or provided) skeletal structure.",
+
+ "Originally discovered by NT when a researcher \
+ fell into an open tank of liquid plasma, the previously unnoticed fungal colony overtook the body creating \
+ the first \"true\" plasmaman. The process has since been streamlined via generous donations of convict corpses and plasmamen \
+ have been deployed en masse throughout NT to bolster the workforce.",
+
+ "New to the galactic stage, plasmamen are a blank slate. \
+ Their appearance, generally regarded as \"ghoulish\", inspires a lot of apprehension in their crewmates. \
+ It might be the whole \"flammable purple skeleton\" thing.",
+
+ "The colonids that make up plasmamen require the plasma-rich atmosphere they evolved in. \
+ Their psuedo-nervous system runs with externalized electrical impulses that immediately ignite their plasma-based bodies when oxygen is present.",
+ )
+
+/datum/species/plasmaman/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "user-shield",
+ SPECIES_PERK_NAME = "Protected",
+ SPECIES_PERK_DESC = "Plasmamen are immune to radiation, poisons, and most diseases.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "hard-hat",
+ SPECIES_PERK_NAME = "Protective Helmet",
+ SPECIES_PERK_DESC = "Plasmamen's helmets provide them shielding from the flashes of welding, as well as an inbuilt flashlight.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "fire",
+ SPECIES_PERK_NAME = "Living Torch",
+ SPECIES_PERK_DESC = "Plasmamen instantly ignite when their body makes contact with oxygen.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "wind",
+ SPECIES_PERK_NAME = "Plasma Breathing",
+ SPECIES_PERK_DESC = "Plasmamen must breathe plasma to survive. You receive a tank when you arrive.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "briefcase-medical",
+ SPECIES_PERK_NAME = "Complex Biology",
+ SPECIES_PERK_DESC = "Plasmamen take specialized medical knowledge to be \
+ treated. Do not expect speedy revival, if you are lucky enough to get \
+ one at all.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/podpeople.dm b/code/modules/mob/living/carbon/human/species_types/podpeople.dm
index 89c660d6be925..7a701871cb2c7 100644
--- a/code/modules/mob/living/carbon/human/species_types/podpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/podpeople.dm
@@ -1,6 +1,7 @@
/datum/species/pod
// A mutation caused by a human being ressurected in a revival pod. These regain health in light, and begin to wither in darkness.
name = "\improper Podperson"
+ plural_form = "Podpeople"
id = SPECIES_PODPERSON
default_color = "59CE00"
species_traits = list(MUTCOLORS,EYECOLOR)
diff --git a/code/modules/mob/living/carbon/human/species_types/psyphoza.dm b/code/modules/mob/living/carbon/human/species_types/psyphoza.dm
index e568a453a09f4..2a8fc79a91b15 100644
--- a/code/modules/mob/living/carbon/human/species_types/psyphoza.dm
+++ b/code/modules/mob/living/carbon/human/species_types/psyphoza.dm
@@ -17,6 +17,7 @@ GLOBAL_LIST_INIT(psychic_sense_blacklist, typecacheof(list(/turf/open, /obj/mach
/datum/species/psyphoza
name = "\improper Psyphoza"
+ plural_form = "Psyphoza"
id = SPECIES_PSYPHOZA
bodyflag = FLAG_PSYPHOZA
meat = /obj/item/reagent_containers/food/snacks/meat/slab/human/mutant/psyphoza
@@ -39,6 +40,7 @@ GLOBAL_LIST_INIT(psychic_sense_blacklist, typecacheof(list(/turf/open, /obj/mach
mutant_bodyparts = list("psyphoza_cap")
default_features = list("psyphoza_cap" = "Portobello", "body_size" = "Normal")
+ hair_color = "fixedmutcolor"
species_chest = /obj/item/bodypart/chest/psyphoza
species_head = /obj/item/bodypart/head/psyphoza
@@ -80,6 +82,32 @@ GLOBAL_LIST_INIT(psychic_sense_blacklist, typecacheof(list(/turf/open, /obj/mach
. = ..()
PH?.Trigger()
+/datum/species/psyphoza/get_species_description()
+ return "..."
+
+/datum/species/psyphoza/get_species_lore()
+ return list("...")
+
+/datum/species/psyphoza/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "lightbulb",
+ SPECIES_PERK_NAME = "Psychic",
+ SPECIES_PERK_DESC = "Psyphoza are psychic and can sense things others can't.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "eye",
+ SPECIES_PERK_NAME = "Blind",
+ SPECIES_PERK_DESC = "Psyphoza are blind and can't see outside their immediate location and psychic sense.",
+ ),
+ )
+
+ return to_add
+
//This originally held the psychic action until I moved it to the eyes, keep it please.
/obj/item/organ/brain/psyphoza
name = "psyphoza brain"
@@ -144,7 +172,6 @@ GLOBAL_LIST_INIT(psychic_sense_blacklist, typecacheof(list(/turf/open, /obj/mach
auto_action.Grant(M)
///Start auto timer
addtimer(CALLBACK(src, PROC_REF(auto_sense)), auto_cooldown)
- //
/datum/action/item_action/organ_action/psychic_highlight/IsAvailable()
if(has_cooldown_timer)
@@ -159,10 +186,6 @@ GLOBAL_LIST_INIT(psychic_sense_blacklist, typecacheof(list(/turf/open, /obj/mach
has_cooldown_timer = TRUE
UpdateButtonIcon()
addtimer(CALLBACK(src, PROC_REF(finish_cooldown)), cooldown + (sense_time * min(1, overlays.len / PSYCHIC_OVERLAY_UPPER)))
- var/atom/movable/screen/plane_master/psychic/wall/PW = locate(/atom/movable/screen/plane_master/psychic/wall) in owner.client?.screen
- if(PW && !length(PW.filters))
- PW.alpha = 255
- PW.filters += filter(type = "alpha", x = 0, y = 0, icon = icon('icons/mob/psychic.dmi', "e"))
/datum/action/item_action/organ_action/psychic_highlight/UpdateButtonIcon(status_only = FALSE, force = FALSE)
. = ..()
@@ -207,11 +230,6 @@ GLOBAL_LIST_INIT(psychic_sense_blacklist, typecacheof(list(/turf/open, /obj/mach
if(B)
animate(B, alpha = 255)
animate(B, alpha = 0, time = sense_time, easing = SINE_EASING, flags = EASE_IN)
- //Wall nearby highlighting
- var/atom/movable/screen/plane_master/psychic/wall/PW = locate(/atom/movable/screen/plane_master/psychic/wall) in owner.client?.screen
- if(PW)
- animate(PW, alpha = 0)
- animate(PW, alpha = 255, time = sense_time, easing = SINE_EASING, flags = EASE_IN)
//Setup timer to delete image
if(overlay_timer)
deltimer(overlay_timer)
@@ -317,7 +335,14 @@ GLOBAL_LIST_INIT(psychic_sense_blacklist, typecacheof(list(/turf/open, /obj/mach
var/visual_index = 0
/atom/movable/screen/fullscreen/blind/psychic_highlight/wall
- plane = PSYCHIC_WALL_PLANE
+ plane = FULLSCREEN_PLANE
+ blend_mode = BLEND_DEFAULT
+ layer = 4.1
+
+/atom/movable/screen/fullscreen/blind/psychic_highlight/wall/Initialize(mapload)
+ . = ..()
+ filters += filter(type = "alpha", render_source = "*WALL_PLANE_RENDER_TARGET")
+ filters += filter(type = "alpha", icon = icon('icons/mob/psychic.dmi', "e"))
/atom/movable/screen/fullscreen/blind/psychic_highlight/Initialize(mapload)
. = ..()
@@ -414,18 +439,5 @@ GLOBAL_LIST_INIT(psychic_sense_blacklist, typecacheof(list(/turf/open, /obj/mach
if(psychic_action?.auto_sense)
return FALSE
-/proc/generate_psychic_overlay(atom/target)
- var/mutable_appearance/M = new()
- M.appearance = target.appearance
- M.transform = target.transform
- M.pixel_x = 0 //Reset pixel adjustments to avoid bug where overlays tower
- M.pixel_y = 0
- M.pixel_z = 0
- M.pixel_w = 0
- M.plane = PSYCHIC_WALL_PLANE //Draw overlay on this plane so we can use it as a mask
- M.dir = target.dir
-
- return M
-
#undef PSYCHIC_OVERLAY_UPPER
#undef PSYPHOZA_BURNMOD
diff --git a/code/modules/mob/living/carbon/human/species_types/pumpkin_man.dm b/code/modules/mob/living/carbon/human/species_types/pumpkin_man.dm
index 2cecdd5aeb955..aac7938637606 100644
--- a/code/modules/mob/living/carbon/human/species_types/pumpkin_man.dm
+++ b/code/modules/mob/living/carbon/human/species_types/pumpkin_man.dm
@@ -1,5 +1,6 @@
/datum/species/pod/pumpkin_man
name = "\improper Pumpkinperson"
+ plural_form = "Pumpkinpeople"
id = SPECIES_PUMPKINPERSON
sexes = 0
meat = /obj/item/reagent_containers/food/snacks/pumpkinpieslice
@@ -21,7 +22,27 @@
/datum/species/pod/pumpkin_man/check_roundstart_eligible()
if(SSevents.holidays && SSevents.holidays[HALLOWEEN])
return TRUE
- return FALSE
+ return ..()
+
+/datum/species/pod/pumpkin_man/get_species_description()
+ return "A rare subspecies of the Podpeople, Pumpkinpeople are gourdy and orange, appearing every halloween."
+
+/datum/species/pod/pumpkin_man/get_species_lore()
+ return null
+
+/datum/species/pod/pumpkin_man/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "candy-cane",
+ SPECIES_PERK_NAME = "Candy Head!",
+ SPECIES_PERK_DESC = "The heads of Pumpkinpeople are known to create delicious candy. Be careful though, take too much and you might pull your brain out!",
+ ),
+ )
+
+ return to_add
/obj/item/organ/brain/pumpkin_brain
name = "pumpkinperson brain"
diff --git a/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm b/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm
index ec75e0d0257d8..06fe781f3ffca 100644
--- a/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm
@@ -3,7 +3,8 @@
/datum/species/shadow
// Humans cursed to stay in the darkness, lest their life forces drain. They regain health in shadow and die in light.
- name = "???"
+ name = "\improper Shadow"
+ plural_form = "Shadowpeople"
id = SPECIES_SHADOWPERSON
sexes = 0
meat = /obj/item/reagent_containers/food/snacks/meat/slab/human/mutant/shadow
@@ -37,6 +38,55 @@
return TRUE
return ..()
+/datum/species/shadow/get_species_description()
+ return "Victims of a long extinct space alien. Their flesh is a sickly \
+ seethrough filament, their tangled insides in clear view. Their form \
+ is a mockery of life, leaving them mostly unable to work with others under \
+ normal circumstances."
+
+/datum/species/shadow/get_species_lore()
+ return list(
+ "Long ago, the Spinward Sector used to be inhabited by terrifying aliens aptly named \"Shadowlings\" \
+ after their control over darkness, and tendancy to kidnap victims into the dark maintenance shafts. \
+ Around 2558, the long campaign Nanotrasen waged against the space terrors ended with the full extinction of the Shadowlings.",
+
+ "Victims of their kidnappings would become brainless thralls, and via surgery they could be freed from the Shadowling's control. \
+ Those more unlucky would have their entire body transformed by the Shadowlings to better serve in kidnappings. \
+ Unlike the brain tumors of lesser control, these greater thralls could not be reverted.",
+
+ "With Shadowlings long gone, their will is their own again. But their bodies have not reverted, burning in exposure to light. \
+ Nanotrasen has assured the victims that they are searching for a cure. No further information has been given, even years later. \
+ Most shadowpeople now assume Nanotrasen has long since shelfed the project.",
+ )
+
+/datum/species/shadow/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "moon",
+ SPECIES_PERK_NAME = "Shadowborn",
+ SPECIES_PERK_DESC = "Their skin blooms in the darkness. All kinds of damage, \
+ no matter how extreme, will heal over time as long as there is no light.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "eye",
+ SPECIES_PERK_NAME = "Nightvision",
+ SPECIES_PERK_DESC = "Their eyes are adapted to the night, and can see in the dark with no problems.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "sun",
+ SPECIES_PERK_NAME = "Lightburn",
+ SPECIES_PERK_DESC = "Their flesh withers in the light. Any exposure to light is \
+ incredibly painful for the shadowperson, charring their skin.",
+ ),
+ )
+
+ return to_add
+
/datum/species/shadow/nightmare
name = "Nightmare"
id = "nightmare"
@@ -78,7 +128,7 @@
icon_state = "brain-x-d"
var/obj/effect/proc_holder/spell/targeted/shadowwalk/shadowwalk
-/obj/item/organ/brain/nightmare/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/brain/nightmare/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
if(M.dna.species.id != "nightmare")
M.set_species(/datum/species/shadow/nightmare)
@@ -88,7 +138,7 @@
shadowwalk = SW
-/obj/item/organ/brain/nightmare/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/brain/nightmare/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
if(shadowwalk)
M.RemoveSpell(shadowwalk)
..()
@@ -118,13 +168,13 @@
user.temporarilyRemoveItemFromInventory(src, TRUE)
Insert(user)
-/obj/item/organ/heart/nightmare/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/nightmare/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
if(special != HEART_SPECIAL_SHADOWIFY)
blade = new/obj/item/light_eater
M.put_in_hands(blade)
-/obj/item/organ/heart/nightmare/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/nightmare/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
respawn_progress = 0
if(blade && special != HEART_SPECIAL_SHADOWIFY)
M.visible_message("\The [blade] disintegrates!")
diff --git a/code/modules/mob/living/carbon/human/species_types/skeletons.dm b/code/modules/mob/living/carbon/human/species_types/skeletons.dm
index ff02bc9539e2e..ad3a3284bc672 100644
--- a/code/modules/mob/living/carbon/human/species_types/skeletons.dm
+++ b/code/modules/mob/living/carbon/human/species_types/skeletons.dm
@@ -1,6 +1,7 @@
/datum/species/skeleton
// 2spooky
name = "\improper Spooky Scary Skeleton"
+ plural_form = "Skeletons"
id = SPECIES_SKELETON
sexes = 0
meat = /obj/item/reagent_containers/food/snacks/meat/slab/human/mutant/skeleton
@@ -61,3 +62,15 @@
H.reagents.remove_reagent(chem.type, chem.metabolization_rate)
return TRUE
return ..()
+
+/datum/species/skeleton/get_species_description()
+ return "A rattling skeleton! They descend upon Space Station 13 \
+ Every year to spook the crew! \"I've got a BONE to pick with you!\""
+
+/datum/species/skeleton/get_species_lore()
+ return list(
+ "Skeletons want to be feared again! Their presence in media has been destroyed, \
+ or at least that's what they firmly believe. They're always the first thing fought in an RPG, \
+ they're Flanderized into pun rolling JOKES, and it's really starting to get to them. \
+ You could say they're deeply RATTLED. Hah."
+ )
diff --git a/code/modules/mob/living/carbon/human/species_types/snail.dm b/code/modules/mob/living/carbon/human/species_types/snail.dm
index 2b2922a3541ad..4cce83b6a141e 100644
--- a/code/modules/mob/living/carbon/human/species_types/snail.dm
+++ b/code/modules/mob/living/carbon/human/species_types/snail.dm
@@ -1,5 +1,6 @@
/datum/species/snail
name = "\improper Snailperson"
+ plural_form = "Snailpeople"
id = SPECIES_SNAILPERSON
offset_features = list(OFFSET_UNIFORM = list(0,0), OFFSET_ID = list(0,0), OFFSET_GLOVES = list(0,0), OFFSET_GLASSES = list(0,4), OFFSET_EARS = list(0,0), OFFSET_SHOES = list(0,0), OFFSET_S_STORE = list(0,0), OFFSET_FACEMASK = list(0,0), OFFSET_HEAD = list(0,0), OFFSET_FACE = list(0,0), OFFSET_BELT = list(0,0), OFFSET_BACK = list(0,0), OFFSET_SUIT = list(0,0), OFFSET_NECK = list(0,0))
default_color = "336600" //vomit green
diff --git a/code/modules/mob/living/carbon/human/species_types/supersoldier.dm b/code/modules/mob/living/carbon/human/species_types/supersoldier.dm
index a0f329ee05d95..c4c5f36d1cdb4 100644
--- a/code/modules/mob/living/carbon/human/species_types/supersoldier.dm
+++ b/code/modules/mob/living/carbon/human/species_types/supersoldier.dm
@@ -1,6 +1,6 @@
/datum/species/human/supersoldier
name = "Super Soldier" //inherited from the real species, for health scanners and things
- id = SPECIES_SUPERSOILDER
+ id = SPECIES_SUPERSOLDIER
examine_limb_id = SPECIES_HUMAN
species_traits = list(EYECOLOR,HAIR,FACEHAIR,LIPS,NOTRANSSTING) //all of these + whatever we inherit from the real species
inherent_traits = list(TRAIT_NOLIMBDISABLE,TRAIT_NOHUNGER,TRAIT_PIERCEIMMUNE,TRAIT_NODISMEMBER,TRAIT_IGNORESLOWDOWN,TRAIT_IGNOREDAMAGESLOWDOWN,TRAIT_STUNIMMUNE,TRAIT_CONFUSEIMMUNE,TRAIT_SLEEPIMMUNE,TRAIT_PUSHIMMUNE,TRAIT_VIRUSIMMUNE,TRAIT_NODISMEMBER,TRAIT_NOSLIPALL,TRAIT_THERMAL_VISION,TRAIT_STRONG_GRABBER,TRAIT_LAW_ENFORCEMENT_METABOLISM,TRAIT_ALWAYS_CLEAN,TRAIT_FEARLESS)
diff --git a/code/modules/mob/living/carbon/human/species_types/vampire.dm b/code/modules/mob/living/carbon/human/species_types/vampire.dm
index 71124ecb8d10a..987bb28e2e0f5 100644
--- a/code/modules/mob/living/carbon/human/species_types/vampire.dm
+++ b/code/modules/mob/living/carbon/human/species_types/vampire.dm
@@ -19,7 +19,7 @@
/datum/species/vampire/check_roundstart_eligible()
if(SSevents.holidays && SSevents.holidays[HALLOWEEN])
return TRUE
- return FALSE
+ return ..()
/datum/species/vampire/on_species_gain(mob/living/carbon/human/C, datum/species/old_species)
. = ..()
@@ -63,6 +63,69 @@
return 1 //Whips deal 2x damage to vampires. Vampire killer.
return 0
+/datum/species/vampire/get_species_description()
+ return "A classy Vampire! They descend upon Space Station Thirteen Every year to spook the crew! \"Bleeg!!\""
+
+/datum/species/vampire/get_species_lore()
+ return list(
+ "Vampires are unholy beings blessed and cursed with The Thirst. \
+ The Thirst requires them to feast on blood to stay alive, and in return it gives them many bonuses."
+ )
+
+/datum/species/vampire/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ to_add += list(
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "bed",
+ SPECIES_PERK_NAME = "Coffin Brooding",
+ SPECIES_PERK_DESC = "Vampires can delay The Thirst and heal by resting in a coffin. So THAT'S why they do that!",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "cross",
+ SPECIES_PERK_NAME = "Against God and Nature",
+ SPECIES_PERK_DESC = "Almost all higher powers are disgusted by the existence of \
+ Vampires, and entering the Chapel is essentially suicide. Do not do it!",
+ ),
+ )
+
+ return to_add
+
+// Vampire blood is special, so it needs to be handled with its own entry.
+/datum/species/vampire/create_pref_blood_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "tint",
+ SPECIES_PERK_NAME = "The Thirst",
+ SPECIES_PERK_DESC = "In place of eating, Vampires suffer from The Thirst. \
+ Thirst of what? Blood! Their tongue allows them to grab people and drink \
+ their blood, and they will die if they run out. As a note, it doesn't \
+ matter whose blood you drink, it will all be converted into your blood \
+ type when consumed.",
+ ))
+
+ return to_add
+
+// There isn't a "Minor Undead" biotype, so we have to explain it in an override (see: dullahans)
+/datum/species/vampire/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "skull",
+ SPECIES_PERK_NAME = "Minor Undead",
+ SPECIES_PERK_DESC = "[name] are minor undead. \
+ Minor undead enjoy some of the perks of being dead, like \
+ not needing to breathe or eat, but do not get many of the \
+ environmental immunities involved with being fully undead.",
+ ))
+
+ return to_add
+
/obj/item/organ/tongue/vampire
name = "vampire tongue"
actions_types = list(/datum/action/item_action/organ_action/vampire)
diff --git a/code/modules/mob/living/carbon/human/species_types/zombies.dm b/code/modules/mob/living/carbon/human/species_types/zombies.dm
index a6f4271c175a2..aaeca112ee2f9 100644
--- a/code/modules/mob/living/carbon/human/species_types/zombies.dm
+++ b/code/modules/mob/living/carbon/human/species_types/zombies.dm
@@ -26,6 +26,12 @@
return TRUE
return ..()
+/datum/species/zombie/get_species_description()
+ return "A rotting zombie! They descend upon Space Station Thirteen Every year to spook the crew! \"Sincerely, the Zombies!\""
+
+/datum/species/zombie/get_species_lore()
+ return list("Zombies have long lasting beef with Botanists. Their last incident involving a lawn with defensive plants has left them very unhinged.")
+
/datum/species/zombie/infectious
name = "\improper Infectious Zombie"
id = "memezombies"
diff --git a/code/modules/mob/living/carbon/monkey/monkey.dm b/code/modules/mob/living/carbon/monkey/monkey.dm
index 2c2ff29861b7a..152f31664d9aa 100644
--- a/code/modules/mob/living/carbon/monkey/monkey.dm
+++ b/code/modules/mob/living/carbon/monkey/monkey.dm
@@ -254,7 +254,7 @@ GLOBAL_LIST_INIT(strippable_monkey_items, create_strippable_list(list(
/obj/item/organ/brain/tumor
name = "teratoma brain"
-/obj/item/organ/brain/tumor/Remove(mob/living/carbon/C, special, no_id_transfer)
+/obj/item/organ/brain/tumor/Remove(mob/living/carbon/C, special, no_id_transfer, pref_load = FALSE)
. = ..()
//Removing it deletes it
if(!QDELETED(src))
diff --git a/code/modules/mob/living/say.dm b/code/modules/mob/living/say.dm
index 003339bcc4969..b3ae07747d93b 100644
--- a/code/modules/mob/living/say.dm
+++ b/code/modules/mob/living/say.dm
@@ -276,10 +276,10 @@ GLOBAL_LIST_INIT(department_radio_keys, list(
listening -= M // remove (added by SEE_INVISIBLE_MAXIMUM)
continue
if(get_dist(M, src) > 7 || M.get_virtual_z_level() != get_virtual_z_level()) //they're out of range of normal hearing
- if(eavesdrop_range && !(M.client.prefs.chat_toggles & CHAT_GHOSTWHISPER)) //they're whispering and we have hearing whispers at any range off
+ if(eavesdrop_range && !M.client.prefs.read_player_preference(/datum/preference/toggle/chat_ghostwhisper)) //they're whispering and we have hearing whispers at any range off
listening -= M // remove (added by SEE_INVISIBLE_MAXIMUM)
continue
- if(!(M.client.prefs.chat_toggles & CHAT_GHOSTEARS)) //they're talking normally and we have hearing at any range off
+ if(!M.client.prefs.read_player_preference(/datum/preference/toggle/chat_ghostears)) //they're talking normally and we have hearing at any range off
listening -= M // remove (added by SEE_INVISIBLE_MAXIMUM)
continue
listening |= M
@@ -316,7 +316,7 @@ GLOBAL_LIST_INIT(department_radio_keys, list(
//speech bubble
var/list/speech_bubble_recipients = list()
for(var/mob/M in listening)
- if(M.client && !(M.client.prefs.toggles & PREFTOGGLE_RUNECHAT_GLOBAL))
+ if(M.client?.prefs && !M.client.prefs.read_player_preference(/datum/preference/toggle/enable_runechat))
speech_bubble_recipients.Add(M.client)
var/image/I = image('icons/mob/talk.dmi', src, "[bubble_type][say_test(message)]", FLY_LAYER)
I.appearance_flags = APPEARANCE_UI_IGNORE_ALPHA
diff --git a/code/modules/mob/living/silicon/ai/ai.dm b/code/modules/mob/living/silicon/ai/ai.dm
index 776e2d27d95c1..6756e2fc3cc26 100644
--- a/code/modules/mob/living/silicon/ai/ai.dm
+++ b/code/modules/mob/living/silicon/ai/ai.dm
@@ -142,7 +142,7 @@
create_modularInterface()
if(client)
- apply_pref_name("ai",client)
+ INVOKE_ASYNC(src, PROC_REF(apply_pref_name), /datum/preference/name/ai, client)
INVOKE_ASYNC(src, PROC_REF(set_core_display_icon))
@@ -190,7 +190,7 @@
return
if("1", "2", "3", "4", "5", "6", "7", "8", "9")
_key = text2num(_key)
- if(client.keys_held["Ctrl"]) //do we assign a new hotkey?
+ if(user.keys_held["Ctrl"]) //do we assign a new hotkey?
cam_hotkeys[_key] = eyeobj.loc
to_chat(src, "Location saved to Camera Group [_key].")
return
@@ -226,10 +226,10 @@
/mob/living/silicon/ai/proc/set_core_display_icon(input, client/C)
if(client && !C)
C = client
- if(!input && !C?.prefs?.active_character.preferred_ai_core_display)
+ if(!input && !C?.prefs?.read_character_preference(/datum/preference/choiced/ai_core_display))
icon_state = initial(icon_state)
else
- var/preferred_icon = input ? input : C.prefs.active_character.preferred_ai_core_display
+ var/preferred_icon = input ? input : C.prefs.read_character_preference(/datum/preference/choiced/ai_core_display)
icon_state = resolve_ai_icon(preferred_icon)
/mob/living/silicon/ai/verb/pick_icon()
@@ -926,7 +926,7 @@
rendered = "\[Holocall\] [language_icon][speaker.GetVoice()][treated_message]"
var/rendered_scrambled_message
for(var/mob/dead/observer/each_ghost in GLOB.dead_mob_list)
- if(!each_ghost.client || !(each_ghost.client.prefs.toggles & CHAT_GHOSTRADIO))
+ if(!each_ghost.client || !each_ghost.client.prefs.read_player_preference(/datum/preference/toggle/chat_ghostradio))
continue
var/follow_link = FOLLOW_LINK(each_ghost, speaker)
if(each_ghost.has_language(message_language))
diff --git a/code/modules/mob/living/silicon/ai/say.dm b/code/modules/mob/living/silicon/ai/say.dm
index 4840264dfe607..4534bfee57e0f 100644
--- a/code/modules/mob/living/silicon/ai/say.dm
+++ b/code/modules/mob/living/silicon/ai/say.dm
@@ -50,7 +50,7 @@
to_chat(src, message)
for(var/mob/dead/observer/each_ghost in GLOB.dead_mob_list)
- if(!each_ghost.client || !(each_ghost.client.prefs.toggles & CHAT_GHOSTRADIO))
+ if(!each_ghost.client || !each_ghost.client.prefs.read_player_preference(/datum/preference/toggle/chat_ghostradio))
continue
var/follow_link = FOLLOW_LINK(each_ghost, eyeobj || ai_hologram)
to_chat(each_ghost, "[follow_link] [message]")
@@ -154,7 +154,7 @@
if(!only_listener)
// Play voice for all mobs in the z level
for(var/mob/M in GLOB.player_list)
- if(M.client && M.can_hear() && (M.client.prefs.toggles & PREFTOGGLE_SOUND_ANNOUNCEMENTS))
+ if(M.client && M.can_hear() && M.client.prefs.read_player_preference(/datum/preference/toggle/sound_announcements))
var/turf/T = get_turf(M)
if(T.get_virtual_z_level() == z_level)
SEND_SOUND(M, voice)
diff --git a/code/modules/mob/living/silicon/login.dm b/code/modules/mob/living/silicon/login.dm
index 76b171063c4c9..541736da1a6a1 100644
--- a/code/modules/mob/living/silicon/login.dm
+++ b/code/modules/mob/living/silicon/login.dm
@@ -9,6 +9,6 @@
/mob/living/silicon/auto_deadmin_on_login()
if(!client?.holder)
return TRUE
- if(CONFIG_GET(flag/auto_deadmin_silicons) || (client.prefs?.toggles & PREFTOGGLE_DEADMIN_POSITION_SILICON))
+ if(CONFIG_GET(flag/auto_deadmin_silicons) || client.prefs?.read_player_preference(/datum/preference/toggle/deadmin_position_silicon))
return client.holder.auto_deadmin()
return ..()
diff --git a/code/modules/mob/living/silicon/pai/personality.dm b/code/modules/mob/living/silicon/pai/personality.dm
index 056a46db85426..be6ab89acd39f 100644
--- a/code/modules/mob/living/silicon/pai/personality.dm
+++ b/code/modules/mob/living/silicon/pai/personality.dm
@@ -20,7 +20,7 @@
user.client.prefs.pai_name = name
user.client.prefs.pai_description = description
user.client.prefs.pai_comment = comments
- user.client.prefs.save_preferences()
+ user.client.prefs.mark_undatumized_dirty_player()
to_chat(usr, "You have saved pAI information.")
return TRUE
diff --git a/code/modules/mob/living/silicon/robot/robot.dm b/code/modules/mob/living/silicon/robot/robot.dm
index 307e3e0237579..a95e9a8a7b0f9 100644
--- a/code/modules/mob/living/silicon/robot/robot.dm
+++ b/code/modules/mob/living/silicon/robot/robot.dm
@@ -290,8 +290,8 @@
var/changed_name = ""
if(custom_name)
changed_name = custom_name
- if(changed_name == "" && C && C.prefs.active_character.custom_names["cyborg"] != DEFAULT_CYBORG_NAME)
- if(apply_pref_name("cyborg", C))
+ if(changed_name == "" && C && C.prefs.read_character_preference(/datum/preference/name/cyborg) != DEFAULT_CYBORG_NAME)
+ if(apply_pref_name(/datum/preference/name/cyborg, C))
return //built in camera handled in proc
if(!changed_name)
changed_name = get_standard_name()
diff --git a/code/modules/mob/living/simple_animal/friendly/drone/_drone.dm b/code/modules/mob/living/simple_animal/friendly/drone/_drone.dm
index 30f86c43f8dcf..9ab732c688f15 100644
--- a/code/modules/mob/living/simple_animal/friendly/drone/_drone.dm
+++ b/code/modules/mob/living/simple_animal/friendly/drone/_drone.dm
@@ -145,7 +145,7 @@
/mob/living/simple_animal/drone/auto_deadmin_on_login()
if(!client?.holder)
return TRUE
- if(CONFIG_GET(flag/auto_deadmin_silicons) || (client.prefs?.toggles & PREFTOGGLE_DEADMIN_POSITION_SILICON))
+ if(CONFIG_GET(flag/auto_deadmin_silicons) || client.prefs?.read_player_preference(/datum/preference/toggle/deadmin_position_silicon))
return client.holder.auto_deadmin()
return ..()
diff --git a/code/modules/mob/living/simple_animal/hostile/zombie.dm b/code/modules/mob/living/simple_animal/hostile/zombie.dm
index d7742cd633f20..e446b65169bb3 100644
--- a/code/modules/mob/living/simple_animal/hostile/zombie.dm
+++ b/code/modules/mob/living/simple_animal/hostile/zombie.dm
@@ -29,21 +29,21 @@
setup_visuals()
/mob/living/simple_animal/hostile/zombie/proc/setup_visuals()
- var/datum/character_save/CS = new
- CS.pref_species = new /datum/species/zombie
- CS.be_random_body = TRUE
- var/datum/job/J = SSjob.GetJob(zombiejob)
- var/datum/outfit/O
- if(J.outfit)
- O = new J.outfit
- //They have claws now.
- O.r_hand = null
- O.l_hand = null
+ var/datum/job/job = SSjob.GetJob(zombiejob)
+
+ var/datum/outfit/outfit = new job.outfit
+ outfit.l_hand = null
+ outfit.r_hand = null
+
+ var/mob/living/carbon/human/dummy/dummy = new
+ dummy.equipOutfit(outfit)
+ dummy.set_species(/datum/species/zombie)
+ COMPILE_OVERLAYS(dummy)
+ icon = getFlatIcon(dummy)
+ qdel(dummy)
- var/icon/P = get_flat_human_icon("zombie_[zombiejob]", J , CS, "zombie", outfit_override = O)
- icon = P
corpse = new(src)
- corpse.outfit = O
+ corpse.outfit = outfit
corpse.mob_species = /datum/species/zombie
corpse.mob_name = name
diff --git a/code/modules/mob/login.dm b/code/modules/mob/login.dm
index 6708a188a6d0d..00fa1790bd072 100644
--- a/code/modules/mob/login.dm
+++ b/code/modules/mob/login.dm
@@ -35,7 +35,7 @@
create_mob_hud()
if(hud_used)
hud_used.show_hud(hud_used.hud_version)
- hud_used.update_ui_style(ui_style2icon(client.prefs.UI_style))
+ hud_used.update_ui_style(ui_style2icon(client.prefs?.read_player_preference(/datum/preference/choiced/ui_style)))
next_move = 1
@@ -106,8 +106,8 @@
*
* Configs:
* * flag/auto_deadmin_players
- * * client.prefs?.toggles & DEADMIN_ALWAYS
- * * User is antag and flag/auto_deadmin_antagonists or client.prefs?.toggles & DEADMIN_ANTAGONIST
+ * * client?.prefs?.read_player_preference(/datum/preference/toggle/deadmin_always)
+ * * User is antag and flag/auto_deadmin_antagonists or client?.prefs?.read_player_preference(/datum/preference/toggle/deadmin_antagonist)
* * or if their job demands a deadminning SSjob.handle_auto_deadmin_roles()
*
* Called from [login](mob.html#proc/Login)
@@ -115,9 +115,9 @@
/mob/proc/auto_deadmin_on_login() //return true if they're not an admin at the end.
if(!client?.holder)
return TRUE
- if(CONFIG_GET(flag/auto_deadmin_players) || (client.prefs?.toggles & PREFTOGGLE_DEADMIN_ALWAYS))
+ if(CONFIG_GET(flag/auto_deadmin_players) || client?.prefs?.read_player_preference(/datum/preference/toggle/deadmin_always))
return client.holder.auto_deadmin()
- if(mind.has_antag_datum(/datum/antagonist) && (CONFIG_GET(flag/auto_deadmin_antagonists) || client.prefs?.toggles & PREFTOGGLE_DEADMIN_ANTAGONIST))
+ if(mind.has_antag_datum(/datum/antagonist) && (CONFIG_GET(flag/auto_deadmin_antagonists) || client.prefs?.read_player_preference(/datum/preference/toggle/deadmin_antagonist)))
return client.holder.auto_deadmin()
if(job)
return SSjob.handle_auto_deadmin_roles(client, job)
diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm
index 42a007a1b25d0..93c52ca742422 100644
--- a/code/modules/mob/mob.dm
+++ b/code/modules/mob/mob.dm
@@ -281,16 +281,18 @@
///Returns the client runechat visible messages preference according to the message type.
/atom/proc/runechat_prefs_check(mob/target, list/visible_message_flags)
- if(!(target.client?.prefs.toggles & PREFTOGGLE_RUNECHAT_GLOBAL) || !(target.client.prefs.toggles & PREFTOGGLE_RUNECHAT_NONMOBS))
+ if(!target.client?.prefs.read_player_preference(/datum/preference/toggle/enable_runechat))
return FALSE
- if(LAZYFIND(visible_message_flags, CHATMESSAGE_EMOTE) && !(target.client.prefs.toggles & PREFTOGGLE_RUNECHAT_EMOTES))
+ if (!target.client?.prefs.read_player_preference(/datum/preference/toggle/enable_runechat_non_mobs))
+ return FALSE
+ if(LAZYFIND(visible_message_flags, CHATMESSAGE_EMOTE) && !target.client.prefs.read_player_preference(/datum/preference/toggle/see_rc_emotes))
return FALSE
return TRUE
/mob/runechat_prefs_check(mob/target, list/visible_message_flags)
- if(!(target.client?.prefs.toggles & PREFTOGGLE_RUNECHAT_GLOBAL))
+ if(!target.client?.prefs.read_player_preference(/datum/preference/toggle/enable_runechat))
return FALSE
- if(LAZYFIND(visible_message_flags, CHATMESSAGE_EMOTE) && !(target.client.prefs.toggles & PREFTOGGLE_RUNECHAT_EMOTES))
+ if(LAZYFIND(visible_message_flags, CHATMESSAGE_EMOTE) && !target.client.prefs.read_player_preference(/datum/preference/toggle/see_rc_emotes))
return FALSE
return TRUE
@@ -860,6 +862,14 @@
///Add a spell to the mobs spell list
/mob/proc/AddSpell(obj/effect/proc_holder/spell/S)
+ // HACK: Preferences menu creates one of every selectable species.
+ // Some species, like vampires, create spells when they're made.
+ // The "action" is created when those spells Initialize.
+ // Preferences menu can create these assets at *any* time, primarily before
+ // the atoms SS initializes.
+ // That means "action" won't exist.
+ if (isnull(S.action))
+ return
mob_spell_list += S
S.action.Grant(src)
diff --git a/code/modules/mob/mob_helpers.dm b/code/modules/mob/mob_helpers.dm
index 145ecf6c74217..20376364bf4e5 100644
--- a/code/modules/mob/mob_helpers.dm
+++ b/code/modules/mob/mob_helpers.dm
@@ -429,8 +429,9 @@
if(source)
var/atom/movable/screen/alert/notify_action/A = O.throw_alert("[REF(source)]_notify_action", /atom/movable/screen/alert/notify_action)
if(A)
- if(O.client.prefs && O.client.prefs.UI_style)
- A.icon = ui_style2icon(O.client.prefs.UI_style)
+ var/ui_style = O.client?.prefs?.read_player_preference(/datum/preference/choiced/ui_style)
+ if(ui_style)
+ A.icon = ui_style2icon(ui_style)
if (header)
A.name = header
A.desc = message
diff --git a/code/modules/mob/mob_transformation_simple.dm b/code/modules/mob/mob_transformation_simple.dm
index 85627464c6076..077556a0624c3 100644
--- a/code/modules/mob/mob_transformation_simple.dm
+++ b/code/modules/mob/mob_transformation_simple.dm
@@ -47,7 +47,7 @@
D.updateappearance(mutcolor_update=1, mutations_overlay_update=1)
else if(ishuman(M))
var/mob/living/carbon/human/H = M
- client.prefs.active_character.copy_to(H)
+ H.randomize_human_appearance(~RANDOMIZE_SPECIES)
H.dna.update_dna_identity()
if(mind && isliving(M))
diff --git a/code/modules/mob/status_procs.dm b/code/modules/mob/status_procs.dm
index 5611baa3cfc6b..9e681348509cd 100644
--- a/code/modules/mob/status_procs.dm
+++ b/code/modules/mob/status_procs.dm
@@ -43,7 +43,7 @@
/// proc that adds and removes blindness overlays when necessary
/mob/proc/update_blindness(overlay = /atom/movable/screen/fullscreen/blind)
if(stat == UNCONSCIOUS || HAS_TRAIT(src, TRAIT_BLIND) || eye_blind) // UNCONSCIOUS or has blind trait, or has temporary blindness
- if(stat == CONSCIOUS || stat == SOFT_CRIT)
+ if((stat == CONSCIOUS || stat == SOFT_CRIT) && istype(overlay, /atom/movable/screen/alert))
throw_alert("blind", overlay)
overlay_fullscreen("blind", overlay)
// You are blind why should you be able to make out details like color, only shapes near you
diff --git a/code/modules/mob/transform_procs.dm b/code/modules/mob/transform_procs.dm
index c8d2be93cb325..d8720c5cd408c 100644
--- a/code/modules/mob/transform_procs.dm
+++ b/code/modules/mob/transform_procs.dm
@@ -515,7 +515,7 @@
. = new /mob/living/silicon/ai(pick(landmark_loc), null, src)
if(preference_source)
- apply_pref_name("ai",preference_source)
+ apply_pref_name(/datum/preference/name/ai, preference_source)
qdel(src)
diff --git a/code/modules/modular_computers/computers/item/computer.dm b/code/modules/modular_computers/computers/item/computer.dm
index c7393a2134b15..2beee19416427 100644
--- a/code/modules/modular_computers/computers/item/computer.dm
+++ b/code/modules/modular_computers/computers/item/computer.dm
@@ -30,7 +30,7 @@ GLOBAL_LIST_EMPTY(TabletMessengers) // a list of all active messengers, similar
/// List of themes for this device to allow.
var/list/allowed_themes
/// Color used for the Thinktronic Classic theme.
- var/classic_color = "#808000"
+ var/classic_color = COLOR_OLIVE
var/datum/computer_file/program/active_program = null // A currently active program running on the computer.
var/hardware_flag = 0 // A flag that describes this device type
var/last_power_usage = 0
diff --git a/code/modules/modular_computers/computers/item/computer_ui.dm b/code/modules/modular_computers/computers/item/computer_ui.dm
index 2d1e0bdb8e9c7..aac3e834347fa 100644
--- a/code/modules/modular_computers/computers/item/computer_ui.dm
+++ b/code/modules/modular_computers/computers/item/computer_ui.dm
@@ -216,7 +216,7 @@
new_color = tgui_color_picker(user, "Choose a new color for [src]'s flashlight.", "Light Color",light_color)
if(!new_color)
return
- if(color_hex2num(new_color) < 200) //Colors too dark are rejected
+ if(is_color_dark(new_color, 50) ) //Colors too dark are rejected
to_chat(user, "That color is too dark! Choose a lighter one.")
new_color = null
return set_flashlight_color(new_color)
diff --git a/code/modules/modular_computers/computers/item/tablet.dm b/code/modules/modular_computers/computers/item/tablet.dm
index c26c7d280b6fc..a523179d7688e 100644
--- a/code/modules/modular_computers/computers/item/tablet.dm
+++ b/code/modules/modular_computers/computers/item/tablet.dm
@@ -363,13 +363,10 @@
equipped = TRUE
if(!user.client.prefs)
return
- var/pref_theme = user.client.prefs.pda_theme
- if(!theme_locked && !ignore_theme_pref)
- for(var/key in allowed_themes) // i am going to scream. DM lists stop sucking please
- if(allowed_themes[key] == pref_theme)
- device_theme = pref_theme
- break
- classic_color = user.client.prefs.pda_color
+ var/pref_theme = user.client.prefs.read_character_preference(/datum/preference/choiced/pda_theme)
+ if(!theme_locked && !ignore_theme_pref && (pref_theme in allowed_themes))
+ device_theme = allowed_themes[pref_theme]
+ classic_color = user.client.prefs.read_character_preference(/datum/preference/color/pda_classic_color)
/obj/item/modular_computer/tablet/pda/update_icon()
..()
diff --git a/code/modules/modular_computers/file_system/programs/ntmessenger.dm b/code/modules/modular_computers/file_system/programs/ntmessenger.dm
index 19dc2c919924d..36a8e10a80fbf 100644
--- a/code/modules/modular_computers/file_system/programs/ntmessenger.dm
+++ b/code/modules/modular_computers/file_system/programs/ntmessenger.dm
@@ -340,7 +340,7 @@
// Show it to ghosts
var/ghost_message = "[message_data["name"]] PDA Message --> [target_text]: [signal.format_message(include_photo = TRUE)]"
for(var/mob/M in GLOB.player_list)
- if(isobserver(M) && (M.client?.prefs.chat_toggles & CHAT_GHOSTPDA))
+ if(isobserver(M) && M.client?.prefs.read_player_preference(/datum/preference/toggle/chat_ghostpda))
to_chat(M, "[FOLLOW_LINK(M, user)] [ghost_message]")
// Log in the talk log
diff --git a/code/modules/modular_computers/file_system/programs/ntnrc_client.dm b/code/modules/modular_computers/file_system/programs/ntnrc_client.dm
index 5a3ab3bf9a69e..63863f06c0535 100644
--- a/code/modules/modular_computers/file_system/programs/ntnrc_client.dm
+++ b/code/modules/modular_computers/file_system/programs/ntnrc_client.dm
@@ -63,7 +63,7 @@
var/mob/living/user = usr
var/ghost_message = "[user] (as [username]) NTRC Message to [channel.title]: [message]"
for(var/mob/M in GLOB.player_list)
- if(isobserver(M) && (M.client?.prefs.chat_toggles & CHAT_GHOSTPDA)) // TODO tablet-pda add a preference for this (currently frozen)
+ if(isobserver(M) && M.client?.prefs.read_player_preference(/datum/preference/toggle/chat_ghostpda)) // TODO tablet-pda add a preference for this (currently frozen)
to_chat(M, "[FOLLOW_LINK(M, user)] [ghost_message]")
user.log_talk(message, LOG_CHAT, tag="as [username] to channel [channel.title]")
return TRUE
diff --git a/code/modules/ninja/energy_katana.dm b/code/modules/ninja/energy_katana.dm
index 0a8d6a3360995..7fa27ae22a5b3 100644
--- a/code/modules/ninja/energy_katana.dm
+++ b/code/modules/ninja/energy_katana.dm
@@ -47,13 +47,16 @@
/obj/item/energy_katana/pickup(mob/living/user)
..()
- jaunt.Grant(user, src)
+ if(jaunt)
+ jaunt.Grant(user, src)
+ if(user.client)
+ playsound(src, 'sound/items/unsheath.ogg', 25, 1)
user.update_icons()
- playsound(src, 'sound/items/unsheath.ogg', 25, 1)
/obj/item/energy_katana/dropped(mob/user)
..()
- jaunt.Remove(user)
+ if(jaunt)
+ jaunt.Remove(user)
user.update_icons()
//If we hit the Ninja who owns this Katana, they catch it.
diff --git a/code/modules/ninja/ninja_event.dm b/code/modules/ninja/ninja_event.dm
index a3b656b5f09f6..377850be4fe95 100644
--- a/code/modules/ninja/ninja_event.dm
+++ b/code/modules/ninja/ninja_event.dm
@@ -78,8 +78,9 @@ Contents:
/proc/create_space_ninja(spawn_loc)
var/mob/living/carbon/human/new_ninja = new(spawn_loc)
- var/datum/character_save/CS = new()//Randomize appearance for the ninja.
- CS.real_name = "[pick(GLOB.ninja_titles)] [pick(GLOB.ninja_names)]"
- CS.copy_to(new_ninja)
+ new_ninja.randomize_human_appearance(~(RANDOMIZE_NAME|RANDOMIZE_SPECIES))
+ var/new_name = "[pick(GLOB.ninja_titles)] [pick(GLOB.ninja_names)]"
+ new_ninja.name = new_name
+ new_ninja.real_name = new_name
new_ninja.dna.update_dna_identity()
return new_ninja
diff --git a/code/modules/ninja/outfit.dm b/code/modules/ninja/outfit.dm
index dde8c417da0ce..79a2a24c73b21 100644
--- a/code/modules/ninja/outfit.dm
+++ b/code/modules/ninja/outfit.dm
@@ -16,7 +16,9 @@
implants = list(/obj/item/implant/explosive)
-/datum/outfit/ninja/post_equip(mob/living/carbon/human/H)
+/datum/outfit/ninja/post_equip(mob/living/carbon/human/H, visualsOnly = FALSE)
+ if(visualsOnly)
+ return FALSE
if(istype(H.wear_suit, suit))
var/obj/item/clothing/suit/space/space_ninja/S = H.wear_suit
if(istype(H.belt, belt))
diff --git a/code/modules/paperwork/fax_manager.dm b/code/modules/paperwork/fax_manager.dm
index 1c1fed63751c3..ec91733a577ac 100644
--- a/code/modules/paperwork/fax_manager.dm
+++ b/code/modules/paperwork/fax_manager.dm
@@ -138,7 +138,7 @@ GLOBAL_DATUM_INIT(fax_manager, /datum/fax_manager, new)
break
for(var/client/admin in GLOB.admins)
- if((admin.prefs.chat_toggles & CHAT_PRAYER) && (admin.prefs.toggles & PREFTOGGLE_SOUND_PRAYERS))
+ if(admin.prefs.read_player_preference(/datum/preference/toggle/chat_prayer) && admin.prefs.read_player_preference(/datum/preference/toggle/sound_prayers))
SEND_SOUND(admin, sound('sound/items/poster_being_created.ogg'))
// A special piece of paper for the administrator that will open the interface no matter what.
diff --git a/code/modules/projectiles/projectile/magic.dm b/code/modules/projectiles/projectile/magic.dm
index f4d0144b89f18..c74f252ea4b4c 100644
--- a/code/modules/projectiles/projectile/magic.dm
+++ b/code/modules/projectiles/projectile/magic.dm
@@ -251,7 +251,7 @@
new_mob = new path(M.loc)
if("humanoid")
- new_mob = new /mob/living/carbon/human(M.loc)
+ var/mob/living/carbon/human/new_human = new(M.loc)
if(prob(50))
var/list/chooseable_races = list()
@@ -261,15 +261,14 @@
chooseable_races += speciestype
if(chooseable_races.len)
- new_mob.set_species(pick(chooseable_races))
-
- var/datum/character_save/CS = new() //Randomize appearance for the human
- CS.copy_to(new_mob, icon_updates=0)
-
- var/mob/living/carbon/human/H = new_mob
- H.update_hair()
- H.update_body_parts(TRUE)
- H.dna.update_dna_identity()
+ new_human.set_species(pick(chooseable_races))
+
+ // Randomize everything but the species, which was already handled above.
+ new_human.randomize_human_appearance(~RANDOMIZE_SPECIES)
+ new_human.update_hair()
+ new_human.update_body() // is_creating = TRUE
+ new_human.dna.update_dna_identity()
+ new_mob = new_human
if(!new_mob)
return
diff --git a/code/modules/requests/request_manager.dm b/code/modules/requests/request_manager.dm
index 6c3c132b14032..33718209e4364 100644
--- a/code/modules/requests/request_manager.dm
+++ b/code/modules/requests/request_manager.dm
@@ -53,7 +53,7 @@ GLOBAL_DATUM_INIT(requests, /datum/request_manager, new)
/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 & PREFTOGGLE_SOUND_PRAYERS)
+ if(is_chaplain && admin.prefs.read_player_preference(/datum/preference/toggle/chat_prayer) && admin.prefs.read_player_preference(/datum/preference/toggle/sound_prayers))
SEND_SOUND(admin, sound('sound/effects/pray.ogg'))
/**
diff --git a/code/modules/research/nanites/nanite_programs/sensor.dm b/code/modules/research/nanites/nanite_programs/sensor.dm
index 25c91eb885960..3664058f4e89c 100644
--- a/code/modules/research/nanites/nanite_programs/sensor.dm
+++ b/code/modules/research/nanites/nanite_programs/sensor.dm
@@ -275,7 +275,7 @@
/datum/nanite_program/sensor/species/New()
if(!length(allowed_species))
- for(var/id in GLOB.roundstart_races)
+ for(var/id in get_selectable_species())
allowed_species[id] = GLOB.species_list[id]
. = ..()
diff --git a/code/modules/research/techweb/_techweb_node.dm b/code/modules/research/techweb/_techweb_node.dm
index 7c699e51b4036..a9fb62e57e899 100644
--- a/code/modules/research/techweb/_techweb_node.dm
+++ b/code/modules/research/techweb/_techweb_node.dm
@@ -42,9 +42,9 @@
VARSET_TO_LIST(., display_name)
VARSET_TO_LIST(., hidden)
VARSET_TO_LIST(., starting_node)
- VARSET_TO_LIST(., assoc_list_strip_value(prereq_ids))
- VARSET_TO_LIST(., assoc_list_strip_value(design_ids))
- VARSET_TO_LIST(., assoc_list_strip_value(unlock_ids))
+ VARSET_TO_LIST(., assoc_to_keys(prereq_ids))
+ VARSET_TO_LIST(., assoc_to_keys(design_ids))
+ VARSET_TO_LIST(., assoc_to_keys(unlock_ids))
VARSET_TO_LIST(., boost_item_paths)
VARSET_TO_LIST(., autounlock_by_boost)
VARSET_TO_LIST(., export_price)
diff --git a/code/modules/surgery/bodyparts/bodyparts.dm b/code/modules/surgery/bodyparts/bodyparts.dm
index 82d21ee40c349..e9c4d411168f6 100644
--- a/code/modules/surgery/bodyparts/bodyparts.dm
+++ b/code/modules/surgery/bodyparts/bodyparts.dm
@@ -338,7 +338,7 @@
if(mutation_color) //I hate mutations
draw_color = mutation_color
else if(should_draw_greyscale)
- draw_color = (species_color) || (skin_tone && skintone2hex(skin_tone))
+ draw_color = (species_color) || (skin_tone && skintone2hex(skin_tone, include_tag = FALSE))
else
draw_color = null
@@ -369,7 +369,7 @@
draw_color = mutation_color
if(should_draw_greyscale) //Should the limb be colored?
- draw_color ||= (species_color) || (skin_tone && skintone2hex(skin_tone))
+ draw_color ||= (species_color) || (skin_tone && skintone2hex(skin_tone, include_tag = FALSE))
dmg_overlay_type = S.damage_overlay_type
@@ -453,7 +453,7 @@
draw_color = mutation_color
if(should_draw_greyscale) //Should the limb be colored?
- draw_color ||= (species_color) || (skin_tone && skintone2hex(skin_tone))
+ draw_color ||= (species_color) || (skin_tone && skintone2hex(skin_tone, include_tag = FALSE))
if(draw_color)
limb.color = "#[draw_color]"
diff --git a/code/modules/surgery/bodyparts/helpers.dm b/code/modules/surgery/bodyparts/helpers.dm
index 0bea87021943d..5321da254718a 100644
--- a/code/modules/surgery/bodyparts/helpers.dm
+++ b/code/modules/surgery/bodyparts/helpers.dm
@@ -236,7 +236,7 @@
. = L
-/proc/skintone2hex(skin_tone)
+/proc/skintone2hex(skin_tone, include_tag = TRUE)
. = 0
switch(skin_tone)
if("caucasian1")
@@ -267,3 +267,5 @@
. = "ffc905"
if("pink")
. = "D7377D"
+ if(include_tag && .)
+ return "#" + .
diff --git a/code/modules/surgery/organs/appendix.dm b/code/modules/surgery/organs/appendix.dm
index 89937e7b5fc49..0645e4bc6c101 100644
--- a/code/modules/surgery/organs/appendix.dm
+++ b/code/modules/surgery/organs/appendix.dm
@@ -28,14 +28,14 @@
if(M)
M.adjustToxLoss(4, TRUE, TRUE) //forced to ensure people don't use it to gain tox as slime person
-/obj/item/organ/appendix/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/appendix/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
for(var/datum/disease/appendicitis/A in M.diseases)
A.cure()
inflamed = TRUE
update_icon()
..()
-/obj/item/organ/appendix/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/appendix/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
if(inflamed)
M.ForceContractDisease(new /datum/disease/appendicitis(), FALSE, TRUE)
diff --git a/code/modules/surgery/organs/augments_arms.dm b/code/modules/surgery/organs/augments_arms.dm
index 99796f0b99972..a2f133b383260 100644
--- a/code/modules/surgery/organs/augments_arms.dm
+++ b/code/modules/surgery/organs/augments_arms.dm
@@ -112,14 +112,14 @@
to_chat(user, "You modify [src] to be installed on the [zone == BODY_ZONE_R_ARM ? "right" : "left"] arm.")
update_icon()
-/obj/item/organ/cyberimp/arm/Insert(mob/living/carbon/user, special = FALSE, drop_if_replaced = TRUE)
+/obj/item/organ/cyberimp/arm/Insert(mob/living/carbon/user, special = FALSE, drop_if_replaced = TRUE, pref_load = FALSE)
. = ..()
var/side = zone == BODY_ZONE_R_ARM ? 2 : 1
register_hand(user, owner.hand_bodyparts[side])
RegisterSignal(user, COMSIG_KB_MOB_DROPITEM_DOWN, PROC_REF(dropkey)) //We're nodrop, but we'll watch for the drop hotkey anyway and then stow if possible.
RegisterSignal(user, COMSIG_CARBON_POST_ATTACH_LIMB, PROC_REF(limb_attached))
-/obj/item/organ/cyberimp/arm/Remove(mob/living/carbon/user, special = 0)
+/obj/item/organ/cyberimp/arm/Remove(mob/living/carbon/user, special = 0, pref_load = FALSE)
Retract()
unregister_hand(user)
UnregisterSignal(user, list(COMSIG_KB_MOB_DROPITEM_DOWN, COMSIG_CARBON_POST_ATTACH_LIMB))
diff --git a/code/modules/surgery/organs/augments_chest.dm b/code/modules/surgery/organs/augments_chest.dm
index 3e4e9ecdc1149..069b3739b4180 100644
--- a/code/modules/surgery/organs/augments_chest.dm
+++ b/code/modules/surgery/organs/augments_chest.dm
@@ -130,13 +130,13 @@
var/on = FALSE
var/datum/effect_system/trail_follow/ion/ion_trail
-/obj/item/organ/cyberimp/chest/thrusters/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/cyberimp/chest/thrusters/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
. = ..()
if(!ion_trail)
ion_trail = new
ion_trail.set_up(M)
-/obj/item/organ/cyberimp/chest/thrusters/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/cyberimp/chest/thrusters/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
if(on)
toggle(silent = TRUE)
..()
diff --git a/code/modules/surgery/organs/augments_eyes.dm b/code/modules/surgery/organs/augments_eyes.dm
index 6397ce9a5211c..720a84d551c05 100644
--- a/code/modules/surgery/organs/augments_eyes.dm
+++ b/code/modules/surgery/organs/augments_eyes.dm
@@ -15,7 +15,7 @@
var/HUD_type
var/HUD_trait
-/obj/item/organ/cyberimp/eyes/hud/Insert(var/mob/living/carbon/M, var/special = 0, drop_if_replaced = FALSE)
+/obj/item/organ/cyberimp/eyes/hud/Insert(var/mob/living/carbon/M, var/special = 0, drop_if_replaced = FALSE, pref_load = FALSE)
..()
if(HUD_type)
var/datum/atom_hud/H = GLOB.huds[HUD_type]
@@ -23,7 +23,7 @@
if(HUD_trait)
ADD_TRAIT(M, HUD_trait, ORGAN_TRAIT)
-/obj/item/organ/cyberimp/eyes/hud/Remove(var/mob/living/carbon/M, var/special = 0)
+/obj/item/organ/cyberimp/eyes/hud/Remove(var/mob/living/carbon/M, var/special = 0, pref_load = FALSE)
if(HUD_type)
var/datum/atom_hud/H = GLOB.huds[HUD_type]
H.remove_hud_from(M)
diff --git a/code/modules/surgery/organs/augments_internal.dm b/code/modules/surgery/organs/augments_internal.dm
index 5c103e97210b1..9d4e6b4550e47 100644
--- a/code/modules/surgery/organs/augments_internal.dm
+++ b/code/modules/surgery/organs/augments_internal.dm
@@ -89,7 +89,7 @@
stored_items = list()
-/obj/item/organ/cyberimp/brain/anti_drop/Remove(var/mob/living/carbon/M, special = 0)
+/obj/item/organ/cyberimp/brain/anti_drop/Remove(var/mob/living/carbon/M, special = 0, pref_load = FALSE)
if(active)
ui_action_click()
..()
@@ -109,7 +109,7 @@
var/stun_cap_amount = 40
-/obj/item/organ/cyberimp/brain/anti_stun/Remove(mob/living/carbon/M, special = FALSE)
+/obj/item/organ/cyberimp/brain/anti_stun/Remove(mob/living/carbon/M, special = FALSE, pref_load = FALSE)
. = ..()
UnregisterSignal(M, signalCache)
diff --git a/code/modules/surgery/organs/ears.dm b/code/modules/surgery/organs/ears.dm
index da07a1205ca7f..ced7fd9d08f14 100644
--- a/code/modules/surgery/organs/ears.dm
+++ b/code/modules/surgery/organs/ears.dm
@@ -97,16 +97,22 @@
icon_state = "kitty"
bang_protect = -2
-/obj/item/organ/ears/cat/Insert(mob/living/carbon/human/H, special = 0, drop_if_replaced = TRUE)
+/obj/item/organ/ears/cat/Insert(mob/living/carbon/human/H, special = 0, drop_if_replaced = TRUE, pref_load = FALSE)
..()
+ if(pref_load)
+ H.update_body()
+ return
if(istype(H))
color = H.hair_color
H.dna.species.mutant_bodyparts |= "ears"
H.dna.features["ears"] = "Cat"
H.update_body()
-/obj/item/organ/ears/cat/Remove(mob/living/carbon/human/H, special = 0)
+/obj/item/organ/ears/cat/Remove(mob/living/carbon/human/H, special = 0, pref_load = FALSE)
..()
+ if(pref_load && istype(H))
+ H.update_body()
+ return
if(istype(H))
color = H.hair_color
H.dna.features["ears"] = "None"
@@ -118,13 +124,13 @@
desc = "The source of a penguin's happy feet."
var/datum/component/waddle
-/obj/item/organ/ears/penguin/Insert(mob/living/carbon/human/H, special = 0, drop_if_replaced = TRUE)
+/obj/item/organ/ears/penguin/Insert(mob/living/carbon/human/H, special = 0, drop_if_replaced = TRUE, pref_load = FALSE)
. = ..()
if(istype(H))
to_chat(H, "You suddenly feel like you've lost your balance.")
waddle = H.AddComponent(/datum/component/waddling)
-/obj/item/organ/ears/penguin/Remove(mob/living/carbon/human/H, special = 0)
+/obj/item/organ/ears/penguin/Remove(mob/living/carbon/human/H, special = 0, pref_load = FALSE)
. = ..()
if(istype(H))
to_chat(H, "Your sense of balance comes back to you.")
diff --git a/code/modules/surgery/organs/eyes.dm b/code/modules/surgery/organs/eyes.dm
index 67b981b9a6f38..e0798c3a32b63 100644
--- a/code/modules/surgery/organs/eyes.dm
+++ b/code/modules/surgery/organs/eyes.dm
@@ -33,14 +33,13 @@
///the type of overlay we use for this eye's blind effect
var/atom/movable/screen/fullscreen/blind/blind_type
-/obj/item/organ/eyes/Insert(mob/living/carbon/M, special = FALSE, drop_if_replaced = FALSE, initialising)
+/obj/item/organ/eyes/Insert(mob/living/carbon/M, special = FALSE, drop_if_replaced = FALSE, initialising, pref_load = FALSE)
. = ..()
if(ishuman(owner))
var/mob/living/carbon/human/HMN = owner
old_eye_color = HMN.eye_color
if(eye_color)
HMN.eye_color = eye_color
- HMN.regenerate_icons()
else
eye_color = HMN.eye_color
if(HAS_TRAIT(HMN, TRAIT_NIGHT_VISION) && !lighting_alpha)
@@ -50,12 +49,12 @@
if(M.has_dna() && ishuman(M))
M.dna.species.handle_body(M) //updates eye icon
-/obj/item/organ/eyes/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/eyes/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
if(ishuman(M) && eye_color)
var/mob/living/carbon/human/HMN = M
HMN.eye_color = old_eye_color
- HMN.regenerate_icons()
+ HMN.update_body()
M.update_tint()
M.update_sight()
@@ -176,7 +175,7 @@
/obj/item/organ/eyes/robotic/flashlight/emp_act(severity)
return
-/obj/item/organ/eyes/robotic/flashlight/Insert(mob/living/carbon/M, special = FALSE, drop_if_replaced = FALSE)
+/obj/item/organ/eyes/robotic/flashlight/Insert(mob/living/carbon/M, special = FALSE, drop_if_replaced = FALSE, pref_load = FALSE)
..()
if(!eye)
eye = new /obj/item/flashlight/eyelight()
@@ -186,7 +185,7 @@
M.become_blind("flashlight_eyes")
-/obj/item/organ/eyes/robotic/flashlight/Remove(var/mob/living/carbon/M, var/special = 0)
+/obj/item/organ/eyes/robotic/flashlight/Remove(var/mob/living/carbon/M, var/special = 0, pref_load = FALSE)
eye.on = FALSE
eye.update_brightness(M)
eye.forceMove(src)
@@ -228,7 +227,7 @@
terminate_effects()
. = ..()
-/obj/item/organ/eyes/robotic/glow/Remove(mob/living/carbon/M, special = FALSE)
+/obj/item/organ/eyes/robotic/glow/Remove(mob/living/carbon/M, special = FALSE, pref_load = FALSE)
terminate_effects()
. = ..()
@@ -424,6 +423,6 @@
M.become_blind("uncurable", /atom/movable/screen/fullscreen/blind/psychic)
M.remove_client_colour(/datum/client_colour/monochrome/blind)
-/obj/item/organ/eyes/psyphoza/Remove(mob/living/carbon/M, special)
+/obj/item/organ/eyes/psyphoza/Remove(mob/living/carbon/M, special = FALSE, pref_load = FALSE)
. = ..()
M.cure_blind("uncurable")
diff --git a/code/modules/surgery/organs/heart.dm b/code/modules/surgery/organs/heart.dm
index 4888219fce4c6..97afce1b052a4 100644
--- a/code/modules/surgery/organs/heart.dm
+++ b/code/modules/surgery/organs/heart.dm
@@ -27,7 +27,7 @@
else
icon_state = "[icon_base]-off"
-/obj/item/organ/heart/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
if(!special)
addtimer(CALLBACK(src, PROC_REF(stop_if_unowned)), 120)
@@ -129,12 +129,12 @@
else
last_pump = world.time //lets be extra fair *sigh*
-/obj/item/organ/heart/cursed/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/cursed/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
if(owner)
to_chat(owner, "Your heart has been replaced with a cursed one, you have to pump this one manually otherwise you'll die!")
-/obj/item/organ/heart/cursed/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/heart/cursed/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
..()
M.remove_client_colour(/datum/client_colour/cursed_heart_blood)
diff --git a/code/modules/surgery/organs/organ_internal.dm b/code/modules/surgery/organs/organ_internal.dm
index a72a3a197dcfb..f07ec7a13b8a9 100644
--- a/code/modules/surgery/organs/organ_internal.dm
+++ b/code/modules/surgery/organs/organ_internal.dm
@@ -30,19 +30,25 @@
var/useable = TRUE
var/list/food_reagents = list(/datum/reagent/consumable/nutriment = 5)
+// Players can look at prefs before atoms SS init, and without this
+// they would not be able to see external organs, such as moth wings.
+// This is also necessary because assets SS is before atoms, and so
+// any nonhumans created in that time would experience the same effect.
+INITIALIZE_IMMEDIATE(/obj/item/organ)
+
/obj/item/organ/Initialize()
. = ..()
if(organ_flags & ORGAN_EDIBLE)
AddComponent(/datum/component/edible, initial_reagents = food_reagents, foodtypes = RAW | MEAT | GORE, \
pre_eat = CALLBACK(src, PROC_REF(pre_eat)), on_compost = CALLBACK(src, PROC_REF(pre_compost)) , after_eat = CALLBACK(src, PROC_REF(on_eat_from)))
-/obj/item/organ/proc/Insert(mob/living/carbon/M, special = 0, drop_if_replaced = TRUE)
+/obj/item/organ/proc/Insert(mob/living/carbon/M, special = 0, drop_if_replaced = TRUE, pref_load = FALSE)
if(!iscarbon(M) || owner == M)
return
var/obj/item/organ/replaced = M.getorganslot(slot)
if(replaced)
- replaced.Remove(M, special = 1)
+ replaced.Remove(M, special = 1, pref_load = pref_load)
if(drop_if_replaced)
replaced.forceMove(get_turf(M))
else
@@ -61,7 +67,7 @@
STOP_PROCESSING(SSobj, src)
//Special is for instant replacement like autosurgeons
-/obj/item/organ/proc/Remove(mob/living/carbon/M, special = FALSE)
+/obj/item/organ/proc/Remove(mob/living/carbon/M, special = FALSE, pref_load = FALSE)
owner = null
if(M)
M.internal_organs -= src
diff --git a/code/modules/surgery/organs/stomach.dm b/code/modules/surgery/organs/stomach.dm
index fc7218cf825ed..e4b19ac68016a 100755
--- a/code/modules/surgery/organs/stomach.dm
+++ b/code/modules/surgery/organs/stomach.dm
@@ -77,7 +77,7 @@
H.throw_alert("disgust", /atom/movable/screen/alert/disgusted)
SEND_SIGNAL(H, COMSIG_ADD_MOOD_EVENT, "disgust", /datum/mood_event/disgusted)
-/obj/item/organ/stomach/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/stomach/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
var/mob/living/carbon/human/H = owner
if(istype(H))
H.clear_alert("disgust")
@@ -101,12 +101,12 @@
var/max_charge = 5000 //same as upgraded+ cell
var/charge = 5000
-/obj/item/organ/stomach/battery/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/stomach/battery/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
. = ..()
RegisterSignal(owner, COMSIG_PROCESS_BORGCHARGER_OCCUPANT, PROC_REF(charge))
update_nutrition()
-/obj/item/organ/stomach/battery/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/stomach/battery/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
UnregisterSignal(owner, COMSIG_PROCESS_BORGCHARGER_OCCUPANT)
if(!HAS_TRAIT(owner, TRAIT_NOHUNGER) && HAS_TRAIT(owner, TRAIT_POWERHUNGRY))
owner.nutrition = 0
@@ -173,11 +173,11 @@
max_charge = 2500 //same as upgraded cell
charge = 2500
-/obj/item/organ/stomach/battery/ethereal/Insert(mob/living/carbon/M, special = 0)
+/obj/item/organ/stomach/battery/ethereal/Insert(mob/living/carbon/M, special = 0, pref_load = FALSE)
RegisterSignal(owner, COMSIG_LIVING_ELECTROCUTE_ACT, PROC_REF(on_electrocute))
return ..()
-/obj/item/organ/stomach/battery/ethereal/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/stomach/battery/ethereal/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
UnregisterSignal(owner, COMSIG_LIVING_ELECTROCUTE_ACT)
return ..()
diff --git a/code/modules/surgery/organs/tails.dm b/code/modules/surgery/organs/tails.dm
index d9440688d80ac..63c51a2b67131 100644
--- a/code/modules/surgery/organs/tails.dm
+++ b/code/modules/surgery/organs/tails.dm
@@ -22,16 +22,23 @@
desc = "A severed cat tail. Who's wagging now?"
tail_type = "Cat"
-/obj/item/organ/tail/cat/Insert(mob/living/carbon/human/H, special = 0, drop_if_replaced = TRUE)
+/obj/item/organ/tail/cat/Insert(mob/living/carbon/human/H, special = 0, drop_if_replaced = TRUE, pref_load = FALSE)
..()
+ if(pref_load && istype(H))
+ H.update_body()
+ return
if(istype(H))
if(!("tail_human" in H.dna.species.mutant_bodyparts))
H.dna.species.mutant_bodyparts |= "tail_human"
H.dna.features["tail_human"] = tail_type
H.update_body()
-/obj/item/organ/tail/cat/Remove(mob/living/carbon/human/H, special = 0)
+/obj/item/organ/tail/cat/Remove(mob/living/carbon/human/H, special = 0, pref_load = FALSE)
..()
+ if(pref_load && istype(H))
+ color = H.hair_color
+ H.update_body()
+ return
if(istype(H))
H.dna.features["tail_human"] = "None"
H.dna.species.mutant_bodyparts -= "tail_human"
@@ -77,7 +84,7 @@
H.dna.species.mutant_bodyparts |= "spines"
H.update_body()
-/obj/item/organ/tail/lizard/Remove(mob/living/carbon/human/H, special = 0)
+/obj/item/organ/tail/lizard/Remove(mob/living/carbon/human/H, special = 0, pref_load = FALSE)
..()
if(istype(H))
H.dna.species.mutant_bodyparts -= "tail_lizard"
diff --git a/code/modules/surgery/organs/tongue.dm b/code/modules/surgery/organs/tongue.dm
index 4b23aec7a8361..769db0880eef4 100644
--- a/code/modules/surgery/organs/tongue.dm
+++ b/code/modules/surgery/organs/tongue.dm
@@ -49,7 +49,7 @@
M.UnregisterSignal(M, COMSIG_MOB_SAY)
return ..()
-/obj/item/organ/tongue/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/tongue/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
UnregisterSignal(M, COMSIG_MOB_SAY, PROC_REF(handle_speech))
M.RegisterSignal(M, COMSIG_MOB_SAY, TYPE_PROC_REF(/mob/living/carbon, handle_tongueless_speech))
return ..()
diff --git a/code/modules/surgery/organs/wings.dm b/code/modules/surgery/organs/wings.dm
index d62e84c031473..b5cc85dc7e22c 100644
--- a/code/modules/surgery/organs/wings.dm
+++ b/code/modules/surgery/organs/wings.dm
@@ -37,7 +37,7 @@
if(H.movement_type & FLYING)
H.dna.species.toggle_flight(H)
-/obj/item/organ/wings/Remove(mob/living/carbon/human/H, special = 0)
+/obj/item/organ/wings/Remove(mob/living/carbon/human/H, special = 0, pref_load = FALSE)
..()
if(istype(H))
H.dna.species.mutant_bodyparts -= basewings
@@ -123,7 +123,7 @@
wing_type = "Plain"
canopen = TRUE
-/obj/item/organ/wings/moth/Remove(mob/living/carbon/human/H, special)
+/obj/item/organ/wings/moth/Remove(mob/living/carbon/human/H, special, pref_load = FALSE)
flight_level = initial(flight_level)
return ..()
@@ -172,7 +172,7 @@
wing_type = "Bee"
var/jumpdist = 3
-/obj/item/organ/wings/bee/Remove(mob/living/carbon/human/H, special)
+/obj/item/organ/wings/bee/Remove(mob/living/carbon/human/H, special, pref_load = FALSE)
jumpdist = initial(jumpdist)
return ..()
diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm
index 00a06d29ef850..df6ca97611fc8 100644
--- a/code/modules/tgui/tgui.dm
+++ b/code/modules/tgui/tgui.dm
@@ -57,7 +57,7 @@
/datum/tgui/New(mob/user, datum/src_object, interface, title, ui_x, ui_y)
if(!user.client) // No client to show the TGUI to, so stop here
return
- log_tgui(user, "new [interface] fancy [user.client.prefs.toggles2 & PREFTOGGLE_2_FANCY_TGUI]")
+ log_tgui(user, "new [interface] fancy [user?.client?.prefs.read_player_preference(/datum/preference/toggle/tgui_fancy)]")
src.user = user
src.src_object = src_object
src.window_key = "[REF(src_object)]-main"
@@ -101,7 +101,7 @@
if(!window.is_ready())
window.initialize(
strict_mode = TRUE,
- fancy = (user.client.prefs.toggles & PREFTOGGLE_2_FANCY_TGUI),
+ fancy = user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_fancy),
assets = list(
get_asset_datum(/datum/asset/simple/tgui),
))
@@ -243,8 +243,8 @@
"window" = list(
"key" = window_key,
"size" = window_size,
- "fancy" = (user.client.prefs.toggles2 & PREFTOGGLE_2_FANCY_TGUI),
- "locked" = (user.client.prefs.toggles2 & PREFTOGGLE_2_LOCKED_BUTTONS),
+ "fancy" = user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_fancy),
+ "locked" = user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_lock),
),
"client" = list(
"ckey" = user.client.ckey,
diff --git a/code/modules/tgui_input/alert.dm b/code/modules/tgui_input/alert.dm
index 1f42a25428be8..d1091977f6382 100644
--- a/code/modules/tgui_input/alert.dm
+++ b/code/modules/tgui_input/alert.dm
@@ -20,7 +20,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
switch(length(buttons))
if(1)
return alert(user, message, title, buttons[1])
@@ -60,7 +60,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
if(length(buttons) == 2)
return alert(user, message, title, buttons[1], buttons[2])
if(length(buttons) == 3)
@@ -134,8 +134,8 @@
.["autofocus"] = autofocus
.["buttons"] = buttons
.["message"] = message
- .["large_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_BIG_BUTTONS)
- .["swapped_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_SWITCHED_BUTTONS)
+ .["large_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_large)
+ .["swapped_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_swapped)
.["title"] = title
/datum/tgui_modal/ui_data(mob/user)
diff --git a/code/modules/tgui_input/color.dm b/code/modules/tgui_input/color.dm
index e917f0fd5618b..b2f2c7bd71d17 100644
--- a/code/modules/tgui_input/color.dm
+++ b/code/modules/tgui_input/color.dm
@@ -18,7 +18,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
return input(user, message, title, default) as color|null
var/datum/tgui_color_picker/picker = new(user, message, title, default, timeout, autofocus)
picker.ui_interact(user)
@@ -48,7 +48,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
return input(user, message, title, default) as color|null
var/datum/tgui_color_picker/async/picker = new(user, message, title, default, callback, timeout, autofocus)
picker.ui_interact(user)
@@ -115,8 +115,8 @@
/datum/tgui_color_picker/ui_static_data(mob/user)
. = list()
.["autofocus"] = autofocus
- .["large_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_BIG_BUTTONS)
- .["swapped_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_SWITCHED_BUTTONS)
+ .["large_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_large)
+ .["swapped_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_swapped)
.["title"] = title
.["default_color"] = default
.["message"] = message
diff --git a/code/modules/tgui_input/list.dm b/code/modules/tgui_input/list.dm
index 86354dd504149..240b0f6fe5a35 100644
--- a/code/modules/tgui_input/list.dm
+++ b/code/modules/tgui_input/list.dm
@@ -22,7 +22,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
return input(user, message, title) as null|anything in items
var/datum/tgui_list_input/input = new(user, message, title, items, default, timeout)
input.ui_interact(user)
@@ -56,7 +56,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
return input(user, message, title) as null|anything in items
var/datum/tgui_list_input/async/input = new(user, message, title, items, default, callback, timeout)
input.ui_interact(user)
@@ -146,8 +146,8 @@
. = list()
.["init_value"] = default || items[1]
.["items"] = items
- .["large_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_BIG_BUTTONS)
- .["swapped_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_SWITCHED_BUTTONS)
+ .["large_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_large)
+ .["swapped_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_swapped)
.["message"] = message
.["title"] = title
diff --git a/code/modules/tgui_input/number.dm b/code/modules/tgui_input/number.dm
index fa0655df60d51..31d40b7c113f8 100644
--- a/code/modules/tgui_input/number.dm
+++ b/code/modules/tgui_input/number.dm
@@ -25,7 +25,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
var/input_number = input(user, message, title, default) as null|num
return clamp(round_value ? round(input_number) : input_number, min_value, max_value)
var/datum/tgui_input_number/number_input = new(user, message, title, default, max_value, min_value, timeout, round_value)
@@ -61,7 +61,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
var/input_number = input(user, message, title, default) as null|num
return clamp(round_value ? round(input_number) : input_number, min_value, max_value)
var/datum/tgui_input_number/async/number_input = new(user, message, title, default, max_value, min_value, callback, timeout, round_value)
@@ -150,8 +150,8 @@
.["max_value"] = max_value
.["message"] = message
.["min_value"] = min_value
- .["large_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_BIG_BUTTONS)
- .["swapped_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_SWITCHED_BUTTONS)
+ .["large_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_large)
+ .["swapped_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_swapped)
.["title"] = title
/datum/tgui_input_number/ui_data(mob/user)
diff --git a/code/modules/tgui_input/say_modal/modal.dm b/code/modules/tgui_input/say_modal/modal.dm
index f3aa30e84b95b..b492904e8f7b0 100644
--- a/code/modules/tgui_input/say_modal/modal.dm
+++ b/code/modules/tgui_input/say_modal/modal.dm
@@ -84,13 +84,15 @@
* as soon as the window sends the "ready" message.
*/
/datum/tgui_say/proc/load()
+ if(!client.mob) // client has not fully loaded yet.
+ return
window_open = FALSE
// Width and height are from skin.dmf, no way to not hardcode these unfortunately.
- client.center_window("tgui_say", 231, 30)
+ INVOKE_ASYNC(client, TYPE_PROC_REF(/client, center_window), "tgui_say", 231, 30) // async due to prefs menu
winshow(client, "tgui_say", FALSE)
window.send_message("props", list(
- lightMode = (client?.prefs?.toggles2 & PREFTOGGLE_2_SAY_LIGHT_THEME),
- showRadioPrefix = (client?.prefs?.toggles2 & PREFTOGGLE_2_SAY_SHOW_PREFIX),
+ lightMode = client?.prefs?.read_player_preference(/datum/preference/toggle/tgui_say_light_mode),
+ showRadioPrefix = client?.prefs?.read_player_preference(/datum/preference/toggle/tgui_say_show_prefix),
maxLength = max_length,
))
stop_thinking()
diff --git a/code/modules/tgui_input/text.dm b/code/modules/tgui_input/text.dm
index 008aa3c442c39..cfb2ec0caafb5 100644
--- a/code/modules/tgui_input/text.dm
+++ b/code/modules/tgui_input/text.dm
@@ -25,7 +25,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
if(encode)
if(multiline)
return stripped_multiline_input(user, message, title, default, max_length)
@@ -67,7 +67,7 @@
else
return
// Client does NOT have tgui_input on: Returns regular input
- if(!(user.client?.prefs?.toggles2 & PREFTOGGLE_2_TGUI_INPUT))
+ if(!user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input))
if(encode)
if(multiline)
return stripped_multiline_input(user, message, title, default, max_length)
@@ -154,8 +154,8 @@
.["message"] = message
.["multiline"] = multiline
.["placeholder"] = default // Default is a reserved keyword
- .["large_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_BIG_BUTTONS)
- .["swapped_buttons"] = !user.client?.prefs || (user.client.prefs.toggles2 & PREFTOGGLE_2_SWITCHED_BUTTONS)
+ .["large_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_large)
+ .["swapped_buttons"] = !user.client?.prefs || user.client.prefs.read_player_preference(/datum/preference/toggle/tgui_input_swapped)
.["title"] = title
/datum/tgui_input_text/ui_data(mob/user)
diff --git a/code/modules/tooltip/tooltip.dm b/code/modules/tooltip/tooltip.dm
index 577e16f790437..d6279e830bc70 100644
--- a/code/modules/tooltip/tooltip.dm
+++ b/code/modules/tooltip/tooltip.dm
@@ -108,8 +108,9 @@ Notes:
/proc/openToolTip(mob/user = null, atom/movable/tip_src = null, params = null,title = "",content = "",theme = "")
if(istype(user))
if(user.client && user.client.tooltips)
- if(!theme && user.client.prefs && user.client.prefs.UI_style)
- theme = lowertext(user.client.prefs.UI_style)
+ var/ui_style = user.client?.prefs?.read_player_preference(/datum/preference/choiced/ui_style)
+ if(!theme && ui_style)
+ theme = lowertext(ui_style)
if(!theme)
theme = "default"
user.client.tooltips.show(tip_src, params,title,content,theme)
diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm
index 032363716b109..02d40ef96b98b 100644
--- a/code/modules/unit_tests/_unit_tests.dm
+++ b/code/modules/unit_tests/_unit_tests.dm
@@ -79,6 +79,7 @@
#include "heretic_rituals.dm"
#include "metabolizing.dm"
#include "ntnetwork_tests.dm"
+#include "preference_species.dm"
#include "projectiles.dm"
#include "subsystem_init.dm"
#include "subsystem_metric_sanity.dm"
diff --git a/code/modules/unit_tests/preference_species.dm b/code/modules/unit_tests/preference_species.dm
new file mode 100644
index 0000000000000..f06c894a5f877
--- /dev/null
+++ b/code/modules/unit_tests/preference_species.dm
@@ -0,0 +1,33 @@
+
+/**
+ * Checks that all enabled roundstart species
+ * selectable within the preferences menu
+ * have their info / page setup correctly.
+ */
+/datum/unit_test/preference_species
+
+/datum/unit_test/preference_species/Run()
+
+ // Go though all selectable species to see if they have their page setup correctly.
+ for(var/species_id in get_selectable_species())
+
+ var/species_type = GLOB.species_list[species_id]
+ var/datum/species/species = new species_type()
+
+ // Check the species decription.
+ // If it's not overridden, a stack trace will be thrown (and fail the test).
+ // If it's null, it was improperly overriden. Fail the test.
+ var/species_desc = species.get_species_description()
+ if(isnull(species_desc))
+ Fail("Species [species] ([species_type]) is selectable, but did not properly implement get_species_description().")
+
+ // Check the species lore.
+ // If it's not overridden, a stack trace will be thrown (and fail the test).
+ // If it's null, or returned a list, it was improperly overriden. Fail the test.
+ var/species_lore = species.get_species_lore()
+ if(isnull(species_lore))
+ Fail("Species [species] ([species_type]) is selectable, but did not properly implement get_species_lore().")
+ else if(!islist(species_lore))
+ Fail("Species [species] ([species_type]) is selectable, but did not properly implement get_species_lore() (Did not return a list).")
+
+ qdel(species)
diff --git a/code/modules/unit_tests/preferences.dm b/code/modules/unit_tests/preferences.dm
new file mode 100644
index 0000000000000..20fd6bd7ad6d9
--- /dev/null
+++ b/code/modules/unit_tests/preferences.dm
@@ -0,0 +1,51 @@
+/// Requires all preferences to implement required methods.
+/datum/unit_test/preferences_implement_everything
+
+/datum/unit_test/preferences_implement_everything/Run()
+ var/datum/preferences/preferences = new
+ var/mob/living/carbon/human/human = allocate(/mob/living/carbon/human)
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (preference.preference_type == PREFERENCE_CHARACTER)
+ preference.apply_to_human(human, preference.create_informed_default_value(preferences))
+
+ if (istype(preference, /datum/preference/choiced))
+ var/datum/preference/choiced/choiced_preference = preference
+ choiced_preference.init_possible_values()
+
+ // Smoke-test is_valid
+ preference.is_valid(TRUE)
+ preference.is_valid("string")
+ preference.is_valid(100)
+ preference.is_valid(list(1, 2, 3))
+
+/// Requires all preferences to have a valid, unique preference_type.
+/datum/unit_test/preferences_valid_db_key
+
+/datum/unit_test/preferences_valid_db_key/Run()
+ var/list/known_db_keys = list()
+
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (!istext(preference.db_key))
+ Fail("[preference_type] has an invalid db_key.")
+
+ if (preference.db_key in known_db_keys)
+ Fail("[preference_type] has a non-unique db_key `[preference.db_key]`!")
+
+ known_db_keys += preference.db_key
+
+/// Requires all main features have a main_feature_name
+/datum/unit_test/preferences_valid_main_feature_name
+
+/datum/unit_test/preferences_valid_main_feature_name/Run()
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/choiced/preference = GLOB.preference_entries[preference_type]
+ if (!istype(preference))
+ continue
+
+ if (preference.category != PREFERENCE_CATEGORY_FEATURES && preference.category != PREFERENCE_CATEGORY_CLOTHING)
+ continue
+
+ TEST_ASSERT(!isnull(preference.main_feature_name), "Preference [preference_type] does not have a main_feature_name set!")
diff --git a/code/modules/unit_tests/quirks.dm b/code/modules/unit_tests/quirks.dm
new file mode 100644
index 0000000000000..ad0ca261c8bb5
--- /dev/null
+++ b/code/modules/unit_tests/quirks.dm
@@ -0,0 +1,21 @@
+/// Ensure every quirk has a unique icon
+/datum/unit_test/quirk_icons
+
+/datum/unit_test/quirk_icons/Run()
+ var/list/used_icons = list()
+
+ for (var/datum/quirk/quirk_type as anything in subtypesof(/datum/quirk))
+ if (initial(quirk_type.abstract_parent_type) == quirk_type)
+ continue
+
+ var/icon = initial(quirk_type.icon)
+
+ if (isnull(icon))
+ Fail("[quirk_type] has no icon!")
+ continue
+
+ if (icon in used_icons)
+ Fail("[icon] used in both [quirk_type] and [used_icons[icon]]!")
+ continue
+
+ used_icons[icon] = quirk_type
diff --git a/code/modules/wiremod/components/action/light.dm b/code/modules/wiremod/components/action/light.dm
index 72cd1feebb69d..33ee4898181eb 100644
--- a/code/modules/wiremod/components/action/light.dm
+++ b/code/modules/wiremod/components/action/light.dm
@@ -19,7 +19,7 @@
var/datum/port/input/on
var/max_power = 5
- var/min_lightness = 0.4
+ var/min_lightness = 40
var/shell_light_color
/obj/item/circuit_component/light/get_ui_notices()
@@ -49,9 +49,8 @@
green.set_value(clamp(green.value, 0, 255))
on.set_value(clamp(on.value, 0, 1))
- var/list/hsl = rgb2hsl(red.value || 0, green.value || 0, blue.value || 0)
- var/list/light_col = hsl2rgb(hsl[1], hsl[2], max(min_lightness, hsl[3]))
- shell_light_color = rgb(light_col[1], light_col[2], light_col[3])
+ var/list/hsl = rgb2num(rgb(red.value || 0, green.value || 0, blue.value || 0), COLORSPACE_HSL)
+ shell_light_color = rgb(hsl[1], hsl[2], max(min_lightness, hsl[3]), space=COLORSPACE_HSL)
/obj/item/circuit_component/light/input_received(datum/port/input/port)
if(parent.shell)
diff --git a/code/modules/zombie/organs.dm b/code/modules/zombie/organs.dm
index 0f5d2012f8065..5078c5c945329 100644
--- a/code/modules/zombie/organs.dm
+++ b/code/modules/zombie/organs.dm
@@ -23,11 +23,11 @@
GLOB.zombie_infection_list -= src
. = ..()
-/obj/item/organ/zombie_infection/Insert(var/mob/living/carbon/M, special = 0)
+/obj/item/organ/zombie_infection/Insert(var/mob/living/carbon/M, special = 0, pref_load = FALSE)
. = ..()
START_PROCESSING(SSobj, src)
-/obj/item/organ/zombie_infection/Remove(mob/living/carbon/M, special = 0)
+/obj/item/organ/zombie_infection/Remove(mob/living/carbon/M, special = 0, pref_load = FALSE)
. = ..()
STOP_PROCESSING(SSobj, src)
if(iszombie(M) && old_species && !QDELETED(M) && !special)
diff --git a/config/game_options.txt b/config/game_options.txt
index b55526150326c..2dd67b2b0f95b 100644
--- a/config/game_options.txt
+++ b/config/game_options.txt
@@ -493,31 +493,41 @@ ROUNDSTART_RACES moth
#ROUNDSTART_RACES fly
ROUNDSTART_RACES psyphoza
-
## Races that are better than humans in some ways, but worse in others
ROUNDSTART_RACES apid
ROUNDSTART_RACES ethereal
ROUNDSTART_RACES ipc
ROUNDSTART_RACES oozeling
ROUNDSTART_RACES plasmaman
-#ROUNDSTART_RACES abductor
+
+## Golems
+## ----------------
+
#ROUNDSTART_RACES adamantine_golem
#ROUNDSTART_RACES diamond_golem
#ROUNDSTART_RACES gold_golem
#ROUNDSTART_RACES iron_golem
-#ROUNDSTART_RACES jelly
#ROUNDSTART_RACES plasma_golem
-#ROUNDSTART_RACES shadow
#ROUNDSTART_RACES silver_golem
#ROUNDSTART_RACES uranium_golem
-## Races that are straight upgrades. If these are on expect powergamers to always pick them
-#ROUNDSTART_RACES agent
-#ROUNDSTART_RACES pod
+## Halloween races
+## ----------------
+
+#ROUNDSTART_RACES abductor
+#ROUNDSTART_RACES shadow
+#ROUNDSTART_RACES dullahan
+#ROUNDSTART_RACES pumpkin_man
+#ROUNDSTART_RACES vampire
+
+## OP Halloween races:
#ROUNDSTART_RACES skeleton
-#ROUNDSTART_RACES slime
#ROUNDSTART_RACES zombie
+## Races that are straight upgrades. If these are on expect powergamers to always pick them
+#ROUNDSTART_RACES pod
+
+
## Roundstart no-reset races
##-------------------------------------------------------------------------------------------
## Races defined here will not cause existing characters to be reset to human if they currently have a non-roundstart species defined.
diff --git a/icons/UI_Icons/antags/obsessed.dmi b/icons/UI_Icons/antags/obsessed.dmi
new file mode 100644
index 0000000000000..219a6e594132b
Binary files /dev/null and b/icons/UI_Icons/antags/obsessed.dmi differ
diff --git a/icons/mob/apid_accessories/apid_wings.dmi b/icons/mob/apid_accessories/apid_wings.dmi
new file mode 100644
index 0000000000000..fb22fc2e329eb
Binary files /dev/null and b/icons/mob/apid_accessories/apid_wings.dmi differ
diff --git a/icons/mob/moth_antennae.dmi b/icons/mob/moth_antennae.dmi
index cb5a245fb3d59..f1af2cc15807e 100644
Binary files a/icons/mob/moth_antennae.dmi and b/icons/mob/moth_antennae.dmi differ
diff --git a/icons/mob/moth_markings.dmi b/icons/mob/moth_markings.dmi
index 2429b0aa12dfe..f447b3968ef0e 100644
Binary files a/icons/mob/moth_markings.dmi and b/icons/mob/moth_markings.dmi differ
diff --git a/icons/mob/wings.dmi b/icons/mob/wings.dmi
index da63ff3bbae12..b53f17f495ecc 100644
Binary files a/icons/mob/wings.dmi and b/icons/mob/wings.dmi differ
diff --git a/interface/interface.dm b/interface/interface.dm
index 8ad6c9dbce6c5..45073c32cdfae 100644
--- a/interface/interface.dm
+++ b/interface/interface.dm
@@ -106,7 +106,7 @@ Admin:
src << browse(changelog.get_htmlloader("changelog.html"), "window=changes;size=675x650")
if(prefs.lastchangelog != GLOB.changelog_hash)
prefs.lastchangelog = GLOB.changelog_hash
- prefs.save_preferences()
+ prefs.mark_undatumized_dirty_player()
winset(src, "infowindow.changelog", "font-style=;")
diff --git a/tgui/.eslintrc.yml b/tgui/.eslintrc.yml
index 3f62ae1e83c1d..44c4b1ebe5623 100644
--- a/tgui/.eslintrc.yml
+++ b/tgui/.eslintrc.yml
@@ -13,6 +13,7 @@ env:
plugins:
- sonarjs
- react
+ - unused-imports
settings:
react:
version: '16.10'
@@ -642,7 +643,7 @@ rules:
## Prevent usage of unsafe lifecycle methods
react/no-unsafe: error
## Prevent definitions of unused prop types
- react/no-unused-prop-types: error
+ # react/no-unused-prop-types: error
## Prevent definitions of unused state properties
react/no-unused-state: error
## Prevent usage of setState in componentWillUpdate
@@ -763,3 +764,6 @@ rules:
react/jsx-uses-vars: error
## Prevent missing parentheses around multilines JSX (fixable)
react/jsx-wrap-multilines: error
+ ## Prevents the use of unused imports.
+ ## This could be done by enabling no-unused-vars, but we're doing this for now
+ #unused-imports/no-unused-imports: error
diff --git a/tgui/docs/component-reference.md b/tgui/docs/component-reference.md
index 418c2323ca91c..f29b6d42914e8 100644
--- a/tgui/docs/component-reference.md
+++ b/tgui/docs/component-reference.md
@@ -359,15 +359,14 @@ and displays selected entry.
- See inherited props: [Box](#box)
- See inherited props: [Icon](#icon)
-- `options: string[]` - An array of strings which will be displayed in the
-dropdown when open
-- `selected: string` - Currently selected entry
-- `width: number` - Width of dropdown button and resulting menu
+- `options: string[] | DropdownEntry[]` - An array of strings which will be displayed in the
+dropdown when open. See Dropdown.tsx for more adcanced usage with DropdownEntry
+- `selected: any` - Currently selected entry
+- `width: string` - Width of dropdown button and resulting menu; css width value
- `over: boolean` - Dropdown renders over instead of below
- `color: string` - Color of dropdown button
- `nochevron: boolean` - Whether or not the arrow on the right hand side of the dropdown button is visible
-- `noscroll: boolean` - Whether or not the dropdown menu should have a scroll bar
-- `displayText: string` - Text to always display in place of the selected text
+- `displayText: string | number | InfernoNode` - Text to always display in place of the selected text
- `onClick: (e) => void` - Called when dropdown button is clicked
- `onSelected: (value) => void` - Called when a value is picked from the list, `value` is the value that was picked
diff --git a/tgui/docs/tgui-for-custom-html-popups.md b/tgui/docs/tgui-for-custom-html-popups.md
index 97eaf20446e5c..2c0a73411ed3c 100644
--- a/tgui/docs/tgui-for-custom-html-popups.md
+++ b/tgui/docs/tgui-for-custom-html-popups.md
@@ -35,11 +35,11 @@ window.close()
## Sending assets
-TGUI in /tg/station codebase has `/datum/asset`, that packs scripts and stylesheets for delivery via CDN for efficiency. TGUI internally uses this asset system to render TGUI interfaces *proper* and TGUI chat. This is a snippet from internal TGUI code:
+TGUI in /tg/station codebase has `/datum/asset`, that packs scripts and stylesheets for delivery via CDN for efficiency. TGUI internally uses this asset system to render TGUI interfaces _proper_ and TGUI chat. This is a snippet from internal TGUI code:
```dm
window.initialize(
- fancy = user.client.prefs.read_preference(
+ fancy = user.client.prefs.read_player_preference(
/datum/preference/toggle/tgui_fancy
),
assets = list(
@@ -64,8 +64,8 @@ Finally, you can use the `Byond` API object to load JS and CSS files directly vi
```html
```
@@ -91,7 +91,7 @@ window.initialize(
)
```
-If you need to inline multiple JS or CSS files, you can concatenate them for now, and separate contents of each file with an `\n` symbol. *This can be a point of improvement (add support for file lists)*.
+If you need to inline multiple JS or CSS files, you can concatenate them for now, and separate contents of each file with an `\n` symbol. _This can be a point of improvement (add support for file lists)_.
## Fancy mode
@@ -134,7 +134,7 @@ You can think of it in these terms:
Of course we're not working with functions here, but hopefully this analogy makes the concept easier to understand.
-Finally, message can contain custom properties, and how you use them is *completely up to you*. They have an important limitation - all additional properties are string-typed, and require you to use a slightly more verbose API for sending them (more about it in the next section).
+Finally, message can contain custom properties, and how you use them is _completely up to you_. They have an important limitation - all additional properties are string-typed, and require you to use a slightly more verbose API for sending them (more about it in the next section).
```js
Byond.sendMessage({
@@ -209,8 +209,8 @@ You can send messages with custom fields in case if you want to bypass JSON seri
```js
Byond.sendMessage({
- type: "something",
- ref: "[0x12345678]",
+ type: 'something',
+ ref: '[0x12345678]',
});
```
diff --git a/tgui/package.json b/tgui/package.json
index a613508b7993b..4893883c3847a 100644
--- a/tgui/package.json
+++ b/tgui/package.json
@@ -44,6 +44,7 @@
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-sonarjs": "^0.18.0",
+ "eslint-plugin-unused-imports": "^2.0.0",
"file-loader": "^6.2.0",
"inferno": "^8.2.1",
"jest": "^29.5.0",
diff --git a/tgui/packages/common/collections.ts b/tgui/packages/common/collections.ts
index 8c385f2aaa055..7f5f1c0572366 100644
--- a/tgui/packages/common/collections.ts
+++ b/tgui/packages/common/collections.ts
@@ -69,6 +69,23 @@ export const map: MapFunction =
throw new Error(`map() can't iterate on type ${typeof collection}`);
};
+/**
+ * Given a collection, will run each element through an iteratee function.
+ * Will then filter out undefined values.
+ */
+export const filterMap = (collection: T[], iterateeFn: (value: T) => U | undefined): U[] => {
+ const finalCollection: U[] = [];
+
+ for (const value of collection) {
+ const output = iterateeFn(value);
+ if (output !== undefined) {
+ finalCollection.push(output);
+ }
+ }
+
+ return finalCollection;
+};
+
const COMPARATOR = (objA, objB) => {
const criteriaA = objA.criteria;
const criteriaB = objB.criteria;
@@ -122,6 +139,8 @@ export const sortBy =
return values;
};
+export const sortStrings = sortBy();
+
/**
*
* returns a range of numbers from start to end, exclusively.
@@ -235,3 +254,42 @@ export const zipWith =
(...arrays: T[][]): U[] => {
return map((values: T[]) => iterateeFn(...values))(zip(...arrays));
};
+
+const binarySearch = (getKey: (value: T) => U, collection: readonly T[], inserting: T): number => {
+ if (collection.length === 0) {
+ return 0;
+ }
+
+ const insertingKey = getKey(inserting);
+
+ let [low, high] = [0, collection.length];
+
+ // Because we have checked if the collection is empty, it's impossible
+ // for this to be used before assignment.
+ let compare: U = undefined as unknown as U;
+ let middle = 0;
+
+ while (low < high) {
+ middle = (low + high) >> 1;
+
+ compare = getKey(collection[middle]);
+
+ if (compare < insertingKey) {
+ low = middle + 1;
+ } else if (compare === insertingKey) {
+ return middle;
+ } else {
+ high = middle;
+ }
+ }
+
+ return compare > insertingKey ? middle : middle + 1;
+};
+
+export const binaryInsertWith = (getKey: (value: T) => U): ((collection: readonly T[], value: T) => T[]) => {
+ return (collection, value) => {
+ const copy = [...collection];
+ copy.splice(binarySearch(getKey, collection, value), 0, value);
+ return copy;
+ };
+};
diff --git a/tgui/packages/common/exhaustive.ts b/tgui/packages/common/exhaustive.ts
new file mode 100644
index 0000000000000..bc41757515b08
--- /dev/null
+++ b/tgui/packages/common/exhaustive.ts
@@ -0,0 +1,19 @@
+/**
+ * Throws an error such that a non-exhaustive check will error at compile time
+ * when using TypeScript, rather than at runtime.
+ *
+ * For example:
+ * enum Color { Red, Green, Blue }
+ * switch (color) {
+ * case Color.Red:
+ * return "red";
+ * case Color.Green:
+ * return "green";
+ * default:
+ * // This will error at compile time that we forgot blue.
+ * exhaustiveCheck(color);
+ * }
+ */
+export const exhaustiveCheck = (input: never) => {
+ throw new Error(`Unhandled case: ${input}`);
+};
diff --git a/tgui/packages/tgfont/icons/ATTRIBUTIONS.md b/tgui/packages/tgfont/icons/ATTRIBUTIONS.md
new file mode 100644
index 0000000000000..2f218388d3648
--- /dev/null
+++ b/tgui/packages/tgfont/icons/ATTRIBUTIONS.md
@@ -0,0 +1,6 @@
+bad-touch.svg contains:
+- hug by Phạm Thanh Lộc from the Noun Project
+- Fight by Rudez Studio from the Noun Project
+
+prosthetic-leg.svg contains:
+- prosthetic leg by Gan Khoon Lay from the Noun Project
diff --git a/tgui/packages/tgfont/icons/bad-touch.svg b/tgui/packages/tgfont/icons/bad-touch.svg
new file mode 100644
index 0000000000000..6dc3c9a718a79
--- /dev/null
+++ b/tgui/packages/tgfont/icons/bad-touch.svg
@@ -0,0 +1,23 @@
+
+
+
diff --git a/tgui/packages/tgfont/icons/non-binary.svg b/tgui/packages/tgfont/icons/non-binary.svg
new file mode 100644
index 0000000000000..9aaec674bbbc2
--- /dev/null
+++ b/tgui/packages/tgfont/icons/non-binary.svg
@@ -0,0 +1,17 @@
+
+
+
diff --git a/tgui/packages/tgfont/icons/prosthetic-leg.svg b/tgui/packages/tgfont/icons/prosthetic-leg.svg
new file mode 100644
index 0000000000000..c1f6ceee3fc34
--- /dev/null
+++ b/tgui/packages/tgfont/icons/prosthetic-leg.svg
@@ -0,0 +1,22 @@
+
+
+
diff --git a/tgui/packages/tgui/assets.ts b/tgui/packages/tgui/assets.ts
index beb2f03a52a50..7ae36cb126e9f 100644
--- a/tgui/packages/tgui/assets.ts
+++ b/tgui/packages/tgui/assets.ts
@@ -5,7 +5,7 @@
*/
const EXCLUDED_PATTERNS = [/v4shim/i];
-const loadedMappings = {};
+export const loadedMappings = {};
export const resolveAsset = (name) => loadedMappings[name] || name;
diff --git a/tgui/packages/tgui/components/Box.tsx b/tgui/packages/tgui/components/Box.tsx
index 9a3675da1716f..c44f60acfc1f7 100644
--- a/tgui/packages/tgui/components/Box.tsx
+++ b/tgui/packages/tgui/components/Box.tsx
@@ -10,7 +10,7 @@ import { ChildFlags, VNodeFlags } from 'inferno-vnode-flags';
import { CSS_COLORS } from '../constants';
import type { Inferno, InfernoNode } from 'inferno';
-export interface BoxProps {
+export type BoxProps = {
[key: string]: any;
as?: string;
className?: string | BooleanLike;
@@ -57,7 +57,7 @@ export interface BoxProps {
textColor?: string | BooleanLike;
backgroundColor?: string | BooleanLike;
fillPositionedParent?: boolean;
-}
+};
/**
* Coverts our rem-like spacing unit into a CSS unit.
@@ -238,7 +238,7 @@ export const computeBoxClassName = (props: BoxProps) => {
return classes([isColorClass(color) && 'color-' + color, isColorClass(backgroundColor) && 'color-bg-' + backgroundColor]);
};
-export const Box = (props: BoxProps) => {
+export const Box: Inferno.SFC = (props: BoxProps) => {
const { as = 'div', className, children, ...rest } = props;
// Render props
if (typeof children === 'function') {
diff --git a/tgui/packages/tgui/components/Button.js b/tgui/packages/tgui/components/Button.js
index 1e9d981dc9580..a4662f3db63a5 100644
--- a/tgui/packages/tgui/components/Button.js
+++ b/tgui/packages/tgui/components/Button.js
@@ -35,6 +35,7 @@ export const Button = (props) => {
onclick,
onClick,
verticalAlignContent,
+ captureKeys,
...rest
} = props;
const hasContent = !!(content || children);
@@ -77,6 +78,10 @@ export const Button = (props) => {
])}
tabIndex={!disabled && '0'}
onKeyDown={(e) => {
+ if (captureKeys === false) {
+ return;
+ }
+
const keyCode = window.event ? e.which : e.keyCode;
// Simulate a click when pressing space or enter.
if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) {
diff --git a/tgui/packages/tgui/components/CollapsibleSection.js b/tgui/packages/tgui/components/CollapsibleSection.js
index fc5e98b4a538f..d5441e589e9d5 100644
--- a/tgui/packages/tgui/components/CollapsibleSection.js
+++ b/tgui/packages/tgui/components/CollapsibleSection.js
@@ -3,8 +3,17 @@ import { Section } from './Section';
import { Button } from './Button';
export const CollapsibleSection = (props, context) => {
- const { children, sectionKey, color, buttons = [], forceOpen = false, showButton = !forceOpen, ...rest } = props;
- const [isOpen, setOpen] = useLocalState(context, `open_collapsible_${sectionKey}`, true);
+ const {
+ children,
+ startOpen = true,
+ sectionKey,
+ color,
+ buttons = [],
+ forceOpen = false,
+ showButton = !forceOpen,
+ ...rest
+ } = props;
+ const [isOpen, setOpen] = useLocalState(context, `open_collapsible_${sectionKey}`, startOpen);
return (
{
- if (this.state.open) {
- this.setOpen(false);
- }
- };
- }
-
- componentWillUnmount() {
- window.removeEventListener('click', this.handleClick);
- }
-
- setOpen(open) {
- this.setState({ open: open });
- if (open) {
- setTimeout(() => window.addEventListener('click', this.handleClick));
- this.menuRef.focus();
- } else {
- window.removeEventListener('click', this.handleClick);
- }
- }
-
- setSelected(selected) {
- this.setState({
- selected: selected,
- });
- this.setOpen(false);
- this.props.onSelected(selected);
- }
-
- buildMenu() {
- const { options = [] } = this.props;
- const ops = options.map((option) => (
- {
- this.setSelected(option);
- }}>
- {option}
-
- ));
- return ops.length ? ops : 'No Options Found';
- }
-
- render() {
- const { props } = this;
- const {
- icon,
- iconRotation,
- iconSpin,
- color = 'default',
- over,
- noscroll,
- nochevron,
- width,
- height,
- onClick,
- selected,
- disabled,
- displayText,
- ...boxProps
- } = props;
- let { className, ...rest } = boxProps;
- rest['height'] = null;
-
- const adjustedOpen = over ? !this.state.open : this.state.open;
-
- const menu = this.state.open ? (
- {
- this.menuRef = menu;
- }}
- tabIndex="-1"
- style={{
- 'width': width,
- 'height': height,
- }}
- className={classes([(noscroll && 'Dropdown__menu-noscroll') || 'Dropdown__menu', over && 'Dropdown__over'])}>
- {this.buildMenu()}
-
- ) : null;
-
- return (
-
- {
- if (disabled && !this.state.open) {
- return;
- }
- this.setOpen(!this.state.open);
- }}>
- {icon && }
- {displayText ? displayText : this.state.selected}
- {!!nochevron || (
-
-
-
- )}
-
- {menu}
-
- );
- }
-}
diff --git a/tgui/packages/tgui/components/Dropdown.tsx b/tgui/packages/tgui/components/Dropdown.tsx
new file mode 100644
index 0000000000000..53f2bdc53cae2
--- /dev/null
+++ b/tgui/packages/tgui/components/Dropdown.tsx
@@ -0,0 +1,391 @@
+import { createPopper, VirtualElement } from '@popperjs/core';
+import { classes } from 'common/react';
+import { Component, findDOMFromVNode, InfernoNode, render } from 'inferno';
+import { Box, BoxProps } from './Box';
+import { Button } from './Button';
+import { Icon } from './Icon';
+import { Stack } from './Stack';
+
+export interface DropdownEntry {
+ displayText: string | number | InfernoNode;
+ value: string | number | Enumerator;
+}
+
+export type DropdownRequiredProps = {
+ options: string[] | DropdownEntry[];
+};
+
+export type DropdownOptionalProps = {
+ icon?: string;
+ iconRotation?: number;
+ clipSelectedText?: boolean;
+ width?: string;
+ menuWidth?: string;
+ over?: boolean;
+ color?: string;
+ nochevron?: boolean;
+ displayText?: string | number | InfernoNode;
+ onClick?: (event) => void;
+ // you freaks really are just doing anything with this shit
+ selected?: any;
+ onSelected?: (selected: any) => void;
+ buttons?: boolean;
+ displayHeight?: string;
+};
+
+export type DropdownUniqueProps = DropdownRequiredProps & DropdownOptionalProps;
+
+export type DropdownProps = BoxProps & DropdownUniqueProps;
+
+const DEFAULT_OPTIONS = {
+ placement: 'left-start',
+ modifiers: [
+ {
+ name: 'eventListeners',
+ enabled: false,
+ },
+ ],
+};
+const NULL_RECT: DOMRect = {
+ width: 0,
+ height: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ x: 0,
+ y: 0,
+ toJSON: () => null,
+} as const;
+
+type DropdownState = {
+ selected?: string;
+ open: boolean;
+};
+
+const DROPDOWN_DEFAULT_CLASSNAMES = 'Layout Dropdown__menu';
+const DROPDOWN_SCROLL_CLASSNAMES = 'Layout Dropdown__menu-scroll';
+
+export class Dropdown extends Component {
+ static renderedMenu: HTMLDivElement | undefined;
+ static singletonPopper: ReturnType | undefined;
+ static currentOpenMenu: Element | undefined;
+ static virtualElement: VirtualElement = {
+ getBoundingClientRect: () => Dropdown.currentOpenMenu?.getBoundingClientRect() ?? NULL_RECT,
+ };
+ menuContents: any;
+ handleClick: any;
+ state: DropdownState = {
+ open: false,
+ };
+
+ constructor() {
+ super();
+
+ this.handleClick = () => {
+ if (this.state.open) {
+ this.setOpen(false);
+ }
+ };
+ }
+
+ getDOMNode() {
+ return findDOMFromVNode(this.$LI, true);
+ }
+
+ componentDidMount() {
+ const domNode = this.getDOMNode();
+
+ if (!domNode) {
+ return;
+ }
+ }
+
+ openMenu() {
+ let renderedMenu = Dropdown.renderedMenu;
+ if (renderedMenu === undefined) {
+ renderedMenu = document.createElement('div');
+ renderedMenu.className = DROPDOWN_DEFAULT_CLASSNAMES;
+ document.body.appendChild(renderedMenu);
+ Dropdown.renderedMenu = renderedMenu;
+ }
+
+ const domNode = this.getDOMNode()!;
+ Dropdown.currentOpenMenu = domNode;
+
+ renderedMenu.scrollTop = 0;
+ renderedMenu.style.width =
+ this.props.menuWidth ||
+ // Hack, but domNode should *always* be the parent control meaning it will have width
+ // @ts-ignore
+ `${domNode.offsetWidth}px`;
+ renderedMenu.style.opacity = '1';
+ renderedMenu.style.pointerEvents = 'auto';
+
+ // ie hack
+ // ie has this bizarre behavior where focus just silently fails if the
+ // element being targeted "isn't ready"
+ // 400 is probably way too high, but the lack of hotloading is testing my
+ // patience on tuning it
+ // I'm beyond giving a shit at this point it fucking works whatever
+ setTimeout(() => {
+ Dropdown.renderedMenu?.focus();
+ }, 400);
+ this.renderMenuContent();
+ }
+
+ closeMenu() {
+ if (Dropdown.currentOpenMenu !== this.getDOMNode()) {
+ return;
+ }
+
+ Dropdown.currentOpenMenu = undefined;
+ Dropdown.renderedMenu!.style.opacity = '0';
+ Dropdown.renderedMenu!.style.pointerEvents = 'none';
+ }
+
+ componentWillUnmount() {
+ this.closeMenu();
+ this.setOpen(false);
+ }
+
+ renderMenuContent() {
+ const renderedMenu = Dropdown.renderedMenu;
+ if (!renderedMenu) {
+ return;
+ }
+ if (renderedMenu.offsetHeight > 200) {
+ renderedMenu.className = DROPDOWN_SCROLL_CLASSNAMES;
+ } else {
+ renderedMenu.className = DROPDOWN_DEFAULT_CLASSNAMES;
+ }
+
+ const { options = [] } = this.props;
+ const ops = options.map((option) => {
+ let value, displayText;
+
+ if (typeof option === 'string') {
+ displayText = option;
+ value = option;
+ } else if (option !== null) {
+ displayText = option.displayText;
+ value = option.value;
+ }
+
+ return (
+ {
+ this.setSelected(value);
+ }}>
+ {displayText}
+
+ );
+ });
+
+ const to_render = ops.length ? ops : 'No Options Found';
+
+ render(
+ {to_render} ,
+ renderedMenu,
+ () => {
+ let singletonPopper = Dropdown.singletonPopper;
+ if (singletonPopper === undefined) {
+ singletonPopper = createPopper(Dropdown.virtualElement, renderedMenu!, {
+ ...DEFAULT_OPTIONS,
+ placement: 'bottom-start',
+ });
+
+ Dropdown.singletonPopper = singletonPopper;
+ } else {
+ singletonPopper.setOptions({
+ ...DEFAULT_OPTIONS,
+ placement: 'bottom-start',
+ });
+
+ singletonPopper.update();
+ }
+ },
+ this.context
+ );
+ }
+
+ setOpen(open: boolean) {
+ this.setState((state) => ({
+ ...state,
+ open,
+ }));
+ if (open) {
+ setTimeout(() => {
+ this.openMenu();
+ window.addEventListener('click', this.handleClick);
+ });
+ } else {
+ this.closeMenu();
+ window.removeEventListener('click', this.handleClick);
+ }
+ }
+
+ setSelected(selected: string) {
+ this.setState((state) => ({
+ ...state,
+ selected,
+ }));
+ this.setOpen(false);
+ if (this.props.onSelected) {
+ this.props.onSelected(selected);
+ }
+ }
+
+ getOptionValue(option): string {
+ return typeof option === 'string' ? option : option.value;
+ }
+
+ getSelectedIndex(): number {
+ const selected = this.state.selected || this.props.selected;
+ const { options = [] } = this.props;
+
+ return options.findIndex((option) => {
+ return this.getOptionValue(option) === selected;
+ });
+ }
+
+ toPrevious(): void {
+ const selectedIndex = this.getSelectedIndex();
+
+ if (selectedIndex < 0) {
+ return;
+ }
+
+ const endIndex = this.props.options.length - 1;
+ const previousIndex = selectedIndex === 0 ? endIndex : selectedIndex - 1;
+
+ this.setSelected(this.getOptionValue(this.props.options[previousIndex]));
+ }
+
+ toNext(): void {
+ const selectedIndex = this.getSelectedIndex();
+
+ if (selectedIndex < 0) {
+ return;
+ }
+
+ const endIndex = this.props.options.length - 1;
+ const nextIndex = selectedIndex === endIndex ? 0 : selectedIndex + 1;
+
+ this.setSelected(this.getOptionValue(this.props.options[nextIndex]));
+ }
+
+ render() {
+ const { props } = this;
+ const {
+ icon,
+ iconRotation,
+ iconSpin,
+ clipSelectedText = true,
+ color = 'default',
+ dropdownStyle,
+ over,
+ nochevron,
+ width,
+ onClick,
+ onSelected,
+ selected,
+ disabled,
+ displayText,
+ displayHeight,
+ buttons,
+ ...boxProps
+ } = props;
+ const { className, ...rest } = boxProps;
+
+ const adjustedOpen = over ? !this.state.open : this.state.open;
+
+ return (
+
+
+ {
+ if (disabled && !this.state.open) {
+ return;
+ }
+ this.setOpen(!this.state.open);
+ if (onClick) {
+ onClick(event);
+ }
+ }}
+ {...rest}>
+ {icon && }
+
+ {displayText || this.state.selected}
+
+ {nochevron || (
+
+
+
+ )}
+
+
+ {buttons && (
+ <>
+
+
+ }
+ p={0}
+ disabled={disabled}
+ onClick={() => {
+ if (disabled) {
+ return;
+ }
+
+ this.toPrevious();
+ }}
+ />
+
+
+
+ }
+ p={0}
+ disabled={disabled}
+ onClick={() => {
+ if (disabled) {
+ return;
+ }
+
+ this.toNext();
+ }}
+ />
+
+ >
+ )}
+
+ );
+ }
+}
diff --git a/tgui/packages/tgui/components/FitText.tsx b/tgui/packages/tgui/components/FitText.tsx
new file mode 100644
index 0000000000000..1928c820c4b5c
--- /dev/null
+++ b/tgui/packages/tgui/components/FitText.tsx
@@ -0,0 +1,87 @@
+import { Component, createRef, RefObject } from 'inferno';
+import type { Inferno } from 'inferno';
+
+const DEFAULT_ACCEPTABLE_DIFFERENCE = 5;
+
+export class FitText extends Component<
+ {
+ acceptableDifference?: number;
+ maxWidth: number;
+ maxFontSize: number;
+ native?: Inferno.HTMLAttributes;
+ },
+ {
+ fontSize: number;
+ }
+> {
+ ref: RefObject = createRef();
+ state = {
+ fontSize: 0,
+ };
+
+ constructor() {
+ super();
+
+ this.resize = this.resize.bind(this);
+
+ window.addEventListener('resize', this.resize);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.children !== this.props.children) {
+ this.resize();
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.resize);
+ }
+
+ resize() {
+ const element = this.ref.current;
+ if (!element) {
+ return;
+ }
+
+ const maxWidth = this.props.maxWidth;
+
+ let start = 0;
+ let end = this.props.maxFontSize;
+
+ for (let _ = 0; _ < 10; _++) {
+ const middle = Math.round((start + end) / 2);
+ element.style.fontSize = `${middle}px`;
+
+ const difference = element.offsetWidth - maxWidth;
+
+ if (difference > 0) {
+ end = middle;
+ } else if (difference < (this.props.acceptableDifference ?? DEFAULT_ACCEPTABLE_DIFFERENCE)) {
+ start = middle;
+ } else {
+ break;
+ }
+ }
+
+ this.setState({
+ fontSize: Math.round((start + end) / 2),
+ });
+ }
+
+ componentDidMount() {
+ this.resize();
+ }
+
+ render() {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
diff --git a/tgui/packages/tgui/components/Flex.tsx b/tgui/packages/tgui/components/Flex.tsx
index d37edf57ed9e6..c2036c05b18d5 100644
--- a/tgui/packages/tgui/components/Flex.tsx
+++ b/tgui/packages/tgui/components/Flex.tsx
@@ -5,6 +5,7 @@
*/
import { BooleanLike, classes, pureComponentHooks } from 'common/react';
+import { RefObject } from 'inferno';
import { BoxProps, computeBoxClassName, computeBoxProps, unit } from './Box';
export type FlexProps = BoxProps & {
@@ -52,6 +53,7 @@ export type FlexItemProps = BoxProps & {
shrink?: number;
basis?: string | BooleanLike;
align?: string | BooleanLike;
+ innerRef?: RefObject;
};
export const computeFlexItemClassName = (props: FlexItemProps) => {
@@ -83,7 +85,13 @@ export const computeFlexItemProps = (props: FlexItemProps) => {
const FlexItem = (props) => {
const { className, ...rest } = props;
- return ;
+ return (
+
+ );
};
FlexItem.defaultHooks = pureComponentHooks;
diff --git a/tgui/packages/tgui/components/Icon.js b/tgui/packages/tgui/components/Icon.tsx
similarity index 52%
rename from tgui/packages/tgui/components/Icon.js
rename to tgui/packages/tgui/components/Icon.tsx
index cb88a6ac71c14..b1a3bc6165d9c 100644
--- a/tgui/packages/tgui/components/Icon.js
+++ b/tgui/packages/tgui/components/Icon.tsx
@@ -5,25 +5,39 @@
*/
import { classes, pureComponentHooks } from 'common/react';
-import { computeBoxClassName, computeBoxProps } from './Box';
+import { InfernoNode } from 'inferno';
+import { BoxProps, computeBoxClassName, computeBoxProps } from './Box';
const FA_OUTLINE_REGEX = /-o$/;
-export const Icon = (props) => {
- const { name, size, spin, className, rotation, inverse, ...rest } = props;
+type IconPropsUnique = {
+ name: string;
+ size?: number;
+ spin?: boolean;
+ className?: string;
+ rotation?: number;
+ style?: string | Record;
+};
+
+export type IconProps = IconPropsUnique & BoxProps;
+
+export const Icon = (props: IconProps) => {
+ let { style, ...restlet } = props;
+ const { name, size, spin, className, rotation, ...rest } = restlet;
if (size) {
- if (!rest.style) {
- rest.style = {};
+ if (!style) {
+ style = {};
}
- rest.style['font-size'] = size * 100 + '%';
+ style['font-size'] = size * 100 + '%';
}
- if (typeof rotation === 'number') {
- if (!rest.style) {
- rest.style = {};
+ if (rotation) {
+ if (!style) {
+ style = {};
}
- rest.style['transform'] = `rotate(${rotation}deg)`;
+ style['transform'] = `rotate(${rotation}deg)`;
}
+ rest.style = style;
const boxProps = computeBoxProps(rest);
@@ -42,7 +56,14 @@ export const Icon = (props) => {
Icon.defaultHooks = pureComponentHooks;
-export const IconStack = (props) => {
+type IconStackUnique = {
+ children: InfernoNode;
+ className?: string;
+};
+
+export type IconStackProps = IconStackUnique & BoxProps;
+
+export const IconStack = (props: IconStackProps) => {
const { className, children, ...rest } = props;
return (
diff --git a/tgui/packages/tgui/components/Input.js b/tgui/packages/tgui/components/Input.js
index 73afce0063870..753f9de4087e6 100644
--- a/tgui/packages/tgui/components/Input.js
+++ b/tgui/packages/tgui/components/Input.js
@@ -27,6 +27,7 @@ export class Input extends Component {
if (onInput) {
onInput(e, e.target.value);
}
+ e.preventDefault();
};
this.handleFocus = (e) => {
const { editing } = this.state;
@@ -65,6 +66,11 @@ export class Input extends Component {
return;
}
if (e.keyCode === KEY_ESCAPE) {
+ if (this.props.onEscape) {
+ this.props.onEscape(e);
+ return;
+ }
+
this.setEditing(false);
e.target.value = toInputValue(this.props.value);
e.target.blur();
@@ -79,8 +85,15 @@ export class Input extends Component {
if (input) {
input.value = toInputValue(nextValue);
}
- if (this.props.autoFocus) {
- setTimeout(() => input.focus(), 1);
+
+ if (this.props.autoFocus || this.props.autoSelect) {
+ setTimeout(() => {
+ input.focus();
+
+ if (this.props.autoSelect) {
+ input.select();
+ }
+ }, 1);
}
}
diff --git a/tgui/packages/tgui/components/KeyListener.tsx b/tgui/packages/tgui/components/KeyListener.tsx
new file mode 100644
index 0000000000000..62509cae96d65
--- /dev/null
+++ b/tgui/packages/tgui/components/KeyListener.tsx
@@ -0,0 +1,39 @@
+import { Component } from 'inferno';
+import { KeyEvent } from '../events';
+import { listenForKeyEvents } from '../hotkeys';
+
+type KeyListenerProps = Partial<{
+ onKey: (key: KeyEvent) => void;
+ onKeyDown: (key: KeyEvent) => void;
+ onKeyUp: (key: KeyEvent) => void;
+}>;
+
+export class KeyListener extends Component {
+ dispose: () => void;
+
+ constructor() {
+ super();
+
+ this.dispose = listenForKeyEvents((key) => {
+ if (this.props.onKey) {
+ this.props.onKey(key);
+ }
+
+ if (key.isDown() && this.props.onKeyDown) {
+ this.props.onKeyDown(key);
+ }
+
+ if (key.isUp() && this.props.onKeyUp) {
+ this.props.onKeyUp(key);
+ }
+ });
+ }
+
+ componentWillUnmount() {
+ this.dispose();
+ }
+
+ render() {
+ return null;
+ }
+}
diff --git a/tgui/packages/tgui/components/Popper.tsx b/tgui/packages/tgui/components/Popper.tsx
index dfe00a61cf5c3..1f60f07672583 100644
--- a/tgui/packages/tgui/components/Popper.tsx
+++ b/tgui/packages/tgui/components/Popper.tsx
@@ -64,7 +64,9 @@ export class Popper extends Component {
}
renderPopperContent(callback: () => void) {
- render(this.props.popperContent, this.renderedContent, callback);
+ // `render` errors when given false, so we convert it to `null`,
+ // which is supported.
+ render(this.props.popperContent || null, this.renderedContent, callback, this.context);
}
render() {
diff --git a/tgui/packages/tgui/components/Stack.tsx b/tgui/packages/tgui/components/Stack.tsx
index 8816564f11e99..218374605b051 100644
--- a/tgui/packages/tgui/components/Stack.tsx
+++ b/tgui/packages/tgui/components/Stack.tsx
@@ -5,6 +5,8 @@
*/
import { classes } from 'common/react';
+import { RefObject } from 'inferno';
+import { computeBoxProps } from './Box';
import { computeFlexClassName, computeFlexItemClassName, computeFlexItemProps, computeFlexProps, FlexItemProps, FlexProps } from './Flex';
type StackProps = FlexProps & {
@@ -31,10 +33,16 @@ export const Stack = (props: StackProps) => {
);
};
-const StackItem = (props: FlexProps) => {
- const { className, ...rest } = props;
+type StackItemProps = FlexProps;
+
+const StackItem = (props: StackItemProps) => {
+ const { className, innerRef, ...rest } = props;
return (
-
+
);
};
diff --git a/tgui/packages/tgui/components/TrackOutsideClicks.tsx b/tgui/packages/tgui/components/TrackOutsideClicks.tsx
new file mode 100644
index 0000000000000..e19a6a3753370
--- /dev/null
+++ b/tgui/packages/tgui/components/TrackOutsideClicks.tsx
@@ -0,0 +1,36 @@
+import { Component, createRef } from 'inferno';
+
+export class TrackOutsideClicks extends Component<{
+ onOutsideClick: () => void;
+ removeOnOutsideClick?: boolean;
+}> {
+ ref = createRef();
+
+ constructor() {
+ super();
+
+ this.handleOutsideClick = this.handleOutsideClick.bind(this);
+ document.addEventListener('click', this.handleOutsideClick);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.handleOutsideClick);
+ }
+
+ handleOutsideClick(event: MouseEvent) {
+ if (!(event.target instanceof Node)) {
+ return;
+ }
+
+ if (this.ref.current && !this.ref.current.contains(event.target)) {
+ this.props.onOutsideClick();
+ if (this.props.removeOnOutsideClick) {
+ document.removeEventListener('click', this.handleOutsideClick);
+ }
+ }
+ }
+
+ render() {
+ return {this.props.children} ;
+ }
+}
diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js
index b42f248951e02..d00ad5a296536 100644
--- a/tgui/packages/tgui/components/index.js
+++ b/tgui/packages/tgui/components/index.js
@@ -20,11 +20,13 @@ export { DraggableControl } from './DraggableControl';
export { DraggableClickableControl } from './DraggableClickableControl';
export { Dropdown } from './Dropdown';
export { Flex } from './Flex';
+export { FitText } from './FitText';
export { Grid } from './Grid';
export { Icon } from './Icon';
export { InfinitePlane } from './InfinitePlane';
export { Interactive } from './Interactive';
export { Input } from './Input';
+export { KeyListener } from './KeyListener';
export { Knob } from './Knob';
export { LabeledControls } from './LabeledControls';
export { LabeledList } from './LabeledList';
@@ -45,4 +47,5 @@ export { Table } from './Table';
export { Tabs } from './Tabs';
export { TextArea } from './TextArea';
export { TimeDisplay } from './TimeDisplay';
+export { TrackOutsideClicks } from './TrackOutsideClicks';
export { Tooltip } from './Tooltip';
diff --git a/tgui/packages/tgui/hotkeys.ts b/tgui/packages/tgui/hotkeys.ts
index 2d7f418ad402e..cde80c00deb4d 100644
--- a/tgui/packages/tgui/hotkeys.ts
+++ b/tgui/packages/tgui/hotkeys.ts
@@ -31,6 +31,9 @@ const hotKeysAcquired = [
// State of passed-through keys.
const keyState: Record = {};
+// Custom listeners for key events
+const keyListeners: ((key: KeyEvent) => void)[] = [];
+
// Is hotkey mode on?
let hotkeyMode;
@@ -190,6 +193,35 @@ export const setupHotKeys = () => {
releaseHeldKeys();
});
globalEvents.on('key', (key: KeyEvent) => {
+ for (const keyListener of keyListeners) {
+ keyListener(key);
+ }
handlePassthrough(key);
});
};
+
+/**
+ * Registers for any key events, such as key down or key up.
+ * This should be preferred over directly connecting to keydown/keyup
+ * as it lets tgui prevent the key from reaching BYOND.
+ *
+ * If using in a component, prefer KeyListener, which automatically handles
+ * stopping listening when unmounting.
+ *
+ * @param callback The function to call whenever a key event occurs
+ * @returns A callback to stop listening
+ */
+export const listenForKeyEvents = (callback: (key: KeyEvent) => void): (() => void) => {
+ keyListeners.push(callback);
+
+ let removed = false;
+
+ return () => {
+ if (removed) {
+ return;
+ }
+
+ removed = true;
+ keyListeners.splice(keyListeners.indexOf(callback), 1);
+ };
+};
diff --git a/tgui/packages/tgui/http.ts b/tgui/packages/tgui/http.ts
new file mode 100644
index 0000000000000..8c18cf0c0935b
--- /dev/null
+++ b/tgui/packages/tgui/http.ts
@@ -0,0 +1,12 @@
+/**
+ * An equivalent to `fetch`, except will automatically retry.
+ */
+export const fetchRetry = (url: string, options?: RequestInit, retryTimer: number = 1000): Promise => {
+ return fetch(url, options).catch(() => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ fetchRetry(url, options, retryTimer).then(resolve);
+ }, retryTimer);
+ });
+ });
+};
diff --git a/tgui/packages/tgui/index.js b/tgui/packages/tgui/index.js
index c640f886cbbfa..7554cac0eccf3 100644
--- a/tgui/packages/tgui/index.js
+++ b/tgui/packages/tgui/index.js
@@ -37,6 +37,7 @@ import './styles/themes-ntos/clown-pink.scss';
import './styles/themes-ntos/clown-yellow.scss';
import './styles/themes-ntos/hackerman.scss';
import './styles/themes/generic.scss';
+import './styles/themes/generic-yellow.scss';
import './styles/themes/paper.scss';
import './styles/themes/retro.scss';
import './styles/themes/syndicate.scss';
diff --git a/tgui/packages/tgui/interfaces/Aquarium.js b/tgui/packages/tgui/interfaces/Aquarium.js
index 0f7b364a8cd62..79703265f93ad 100644
--- a/tgui/packages/tgui/interfaces/Aquarium.js
+++ b/tgui/packages/tgui/interfaces/Aquarium.js
@@ -1,5 +1,5 @@
import { useBackend } from '../backend';
-import { Button, Dropdown, Flex, Knob, LabeledControls, Section } from '../components';
+import { Button, Flex, Knob, LabeledControls, Section } from '../components';
import { Window } from '../layouts';
export const Aquarium = (props, context) => {
diff --git a/tgui/packages/tgui/interfaces/IntegratedCircuit/CircuitInfo.js b/tgui/packages/tgui/interfaces/IntegratedCircuit/CircuitInfo.js
index 8cbf6fe249eb9..da0274aaf6855 100644
--- a/tgui/packages/tgui/interfaces/IntegratedCircuit/CircuitInfo.js
+++ b/tgui/packages/tgui/interfaces/IntegratedCircuit/CircuitInfo.js
@@ -1,4 +1,4 @@
-import { Button, Section, Stack, Box } from '../../components';
+import { Button, Stack, Box } from '../../components';
export const CircuitInfo = (props, context) => {
const { name, desc, notices, ...rest } = props;
diff --git a/tgui/packages/tgui/interfaces/NtosNetChat.js b/tgui/packages/tgui/interfaces/NtosNetChat.js
index e833b49417b70..c2a6d1ede1a2d 100644
--- a/tgui/packages/tgui/interfaces/NtosNetChat.js
+++ b/tgui/packages/tgui/interfaces/NtosNetChat.js
@@ -1,5 +1,5 @@
import { useBackend } from '../backend';
-import { Box, Button, Dimmer, Icon, Input, Section, Stack, Table, Tooltip } from '../components';
+import { Box, Button, Dimmer, Icon, Input, Section, Stack } from '../components';
import { NtosWindow } from '../layouts';
// byond defines for the program state
diff --git a/tgui/packages/tgui/interfaces/NtosSecurEye.js b/tgui/packages/tgui/interfaces/NtosSecurEye.js
index cceb1f616b42d..e333ee6605c48 100644
--- a/tgui/packages/tgui/interfaces/NtosSecurEye.js
+++ b/tgui/packages/tgui/interfaces/NtosSecurEye.js
@@ -1,13 +1,7 @@
-import { filter, sortBy } from 'common/collections';
-import { flow } from 'common/fp';
-import { classes } from 'common/react';
-import { createSearch } from 'common/string';
-import { Fragment } from 'inferno';
-import { useBackend, useLocalState } from '../backend';
-import { Button, ByondUi, Input, Section } from '../components';
+import { useBackend } from '../backend';
+import { Button, ByondUi } from '../components';
import { NtosWindow } from '../layouts';
import { prevNextCamera, selectCameras, CameraConsoleContent } from './CameraConsole';
-import { logger } from '../logging';
export const NtosSecurEye = (props, context) => {
const { act, data, config } = useBackend(context);
diff --git a/tgui/packages/tgui/interfaces/OrbitalMap.js b/tgui/packages/tgui/interfaces/OrbitalMap.js
index 401958f8fe4f1..a6508adef48f6 100644
--- a/tgui/packages/tgui/interfaces/OrbitalMap.js
+++ b/tgui/packages/tgui/interfaces/OrbitalMap.js
@@ -345,12 +345,16 @@ export const OrbitalMapDisplay = (props, context) => {
mt={1}
selected="Select Docking Location"
width="100%"
- options={validDockingPorts.map((map_object) => (
-
- ))}
+ options={validDockingPorts.map((map_object) => {
+ return {
+ displayText: map_object.name,
+ value: map_object.id,
+ };
+ })}
+ displayText="Select Docking Location"
onSelected={(value) =>
act('gotoPort', {
- port: value.key,
+ port: value,
})
}
/>
diff --git a/tgui/packages/tgui/interfaces/PaiInterface.tsx b/tgui/packages/tgui/interfaces/PaiInterface.tsx
index 4c9d6d1075d30..b4d3acb86e140 100644
--- a/tgui/packages/tgui/interfaces/PaiInterface.tsx
+++ b/tgui/packages/tgui/interfaces/PaiInterface.tsx
@@ -1,3 +1,4 @@
+import { BooleanLike } from 'common/react';
import { useBackend, useSharedState } from '../backend';
import { Box, Button, LabeledList, Icon, NoticeBox, ProgressBar, Section, Stack, Table, Tabs, Tooltip } from '../components';
import { Window } from '../layouts';
@@ -13,7 +14,7 @@ type PaiInterfaceData = {
master: Master;
ram: number;
records: Records;
- refresh_spam: number;
+ refresh_spam: BooleanLike;
};
type Available = {
@@ -348,7 +349,7 @@ const RecordsDisplay = (props, context) => {
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx
new file mode 100644
index 0000000000000..96a36ceeafe92
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx
@@ -0,0 +1,322 @@
+import { classes } from 'common/react';
+import { useBackend, useLocalState } from '../../backend';
+import { Box, Button, Flex, Section, Stack, Tooltip, Divider, Input, Icon } from '../../components';
+import { PreferencesMenuData } from './data';
+import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
+import { AntagonistData } from './data';
+import { createSearch } from 'common/string';
+
+const AntagSelection = (
+ props: {
+ antagonists: AntagonistData[];
+ name: string;
+ },
+ context
+) => {
+ const { act, data } = useBackend(context);
+ const className = 'PreferencesMenu__Antags__antagSelection';
+
+ const enableAntagsGlobal = (antags: string[]) => {
+ act('set_antags', {
+ antags,
+ toggled: true,
+ character: false,
+ });
+ };
+
+ const disableAntagsGlobal = (antags: string[]) => {
+ act('set_antags', {
+ antags,
+ toggled: false,
+ character: false,
+ });
+ };
+
+ const enableAntagsCharacter = (antags: string[]) => {
+ act('set_antags', {
+ antags,
+ toggled: true,
+ character: true,
+ });
+ };
+
+ const disableAntagsCharacter = (antags: string[]) => {
+ act('set_antags', {
+ antags,
+ toggled: false,
+ character: true,
+ });
+ };
+
+ const isSelectedGlobal = (antag: string) => {
+ return data.enabled_global?.includes(antag);
+ };
+
+ const isSelectedCharacter = (antag: string) => {
+ return data.enabled_character?.includes(antag);
+ };
+
+ const antagonistKeys = props.antagonists.map((antagonist) => antagonist.path);
+
+ return (
+
+
+ );
+};
+
+export const AntagsPage = (_, context) => {
+ let [searchText, setSearchText] = useLocalState(context, 'antag_search', '');
+ let search = createSearch(searchText, (antagonist: AntagonistData) => {
+ return antagonist.name;
+ });
+ return (
+ {
+ if (!serverData) {
+ return Loading loadout data...;
+ }
+ const { antagonists = [], categories = [] } = serverData.antags;
+ return (
+
+ antag.path)}
+ />
+ {searchText !== '' ? (
+
+ ) : (
+ categories.map((category) => (
+ a.category === category)!}
+ />
+ ))
+ )}
+
+ );
+ }}
+ />
+ );
+};
+
+const SearchBar = ({ searchText, setSearchText, allAntags }, context) => {
+ const { act } = useBackend(context);
+ const enableAntags = (character: boolean) => {
+ act('set_antags', {
+ antags: allAntags,
+ toggled: true,
+ character,
+ });
+ };
+
+ const disableAntags = (character: boolean) => {
+ act('set_antags', {
+ antags: allAntags,
+ toggled: false,
+ character,
+ });
+ };
+ return (
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx
new file mode 100644
index 0000000000000..0a6d651e9c3b8
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx
@@ -0,0 +1,174 @@
+import { exhaustiveCheck } from 'common/exhaustive';
+import { useBackend, useLocalState } from '../../backend';
+import { Button, Flex, Stack, Divider } from '../../components';
+import { Window } from '../../layouts';
+import { PreferencesMenuData } from './data';
+import { PageButton } from './PageButton';
+import { AntagsPage } from './AntagsPage';
+import { JobsPage } from './JobsPage';
+import { MainPage } from './MainPage';
+import { SpeciesPage } from './SpeciesPage';
+import { QuirksPage } from './QuirksPage';
+import { LoadoutPage } from './LoadoutPage';
+import { BooleanLike } from 'common/react';
+import { SaveStatus } from './SaveStatus';
+
+enum Page {
+ Antags,
+ Main,
+ Jobs,
+ Species,
+ Quirks,
+ Loadout,
+}
+
+const CharacterProfiles = (props: {
+ activeSlot: number;
+ maxSlot: number;
+ onClick: (index: number) => void;
+ profiles: (string | null)[];
+ content_unlocked: BooleanLike;
+}) => {
+ const { profiles } = props;
+
+ return (
+
+ {profiles.map((profile, slot) => (
+
+ = props.maxSlot}
+ onClick={() => {
+ props.onClick(slot);
+ }}
+ tooltip={!props.content_unlocked && slot >= props.maxSlot ? 'Buy a BYOND Membership to unlock more slots!' : null}
+ fluid>
+ {profile ?? 'New Character'}
+
+
+ ))}
+
+ );
+};
+
+export const CharacterPreferenceWindow = (props, context) => {
+ const { act, data } = useBackend(context);
+ const [currentPage, setCurrentPage] = useLocalState(context, 'currentPage_character', Page.Main);
+
+ let pageContents;
+
+ switch (currentPage) {
+ case Page.Antags:
+ pageContents = ;
+ break;
+ case Page.Jobs:
+ pageContents = ;
+ break;
+ case Page.Main:
+ pageContents = setCurrentPage(Page.Species)} />;
+
+ break;
+ case Page.Species:
+ pageContents = setCurrentPage(Page.Main)} />;
+
+ break;
+ case Page.Quirks:
+ pageContents = ;
+ break;
+ case Page.Loadout:
+ pageContents = ;
+ break;
+ default:
+ exhaustiveCheck(currentPage);
+ }
+
+ return (
+
+ act('open_game_preferences')}
+ />
+
+ >
+ }>
+
+
+
+ {
+ act('change_slot', {
+ slot: slot + 1,
+ });
+ }}
+ profiles={data.character_profiles}
+ />
+
+
+
+
+
+
+
+
+
+
+ Character
+
+
+
+
+
+ {/*
+ Fun fact: This isn't "Jobs" so that it intentionally
+ catches your eyes, because it's really important!
+ */}
+ Occupations
+
+
+
+
+
+ Loadout
+
+
+
+
+
+ Antagonists
+
+
+
+
+
+ Quirks
+
+
+
+
+
+
+
+
+
+ {pageContents}
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreview.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreview.tsx
new file mode 100644
index 0000000000000..71622d4937eea
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreview.tsx
@@ -0,0 +1,14 @@
+import { ByondUi } from '../../components';
+
+export const CharacterPreview = (props: { height: string; id: string }) => {
+ return (
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferenceWindow.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferenceWindow.tsx
new file mode 100644
index 0000000000000..f658aacb74b1e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferenceWindow.tsx
@@ -0,0 +1,83 @@
+import { Stack, Button } from '../../components';
+import { Window } from '../../layouts';
+import { KeybindingsPage } from './KeybindingsPage';
+import { GamePreferencesPage } from './GamePreferencesPage';
+import { PageButton } from './PageButton';
+import { useBackend, useLocalState } from '../../backend';
+import { GamePreferencesSelectedPage, PreferencesMenuData } from './data';
+import { exhaustiveCheck } from 'common/exhaustive';
+import { SaveStatus } from './SaveStatus';
+
+export const GamePreferenceWindow = (
+ props: {
+ startingPage?: GamePreferencesSelectedPage;
+ },
+ context
+) => {
+ const { act, data } = useBackend(context);
+
+ const [currentPage, setCurrentPage] = useLocalState(
+ context,
+ 'currentPage_game',
+ props.startingPage ?? GamePreferencesSelectedPage.Settings
+ );
+
+ let pageContents;
+
+ switch (currentPage) {
+ case GamePreferencesSelectedPage.Keybindings:
+ pageContents = ;
+ break;
+ case GamePreferencesSelectedPage.Settings:
+ pageContents = ;
+ break;
+ default:
+ exhaustiveCheck(currentPage);
+ }
+
+ return (
+
+ act('open_character_preferences')}
+ />
+
+ >
+ }>
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+ Keybindings
+
+
+
+
+
+
+
+
+ {pageContents}
+
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferencesPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferencesPage.tsx
new file mode 100644
index 0000000000000..409945b872fc2
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferencesPage.tsx
@@ -0,0 +1,166 @@
+import { binaryInsertWith, sortBy } from 'common/collections';
+import { useLocalState } from '../../backend';
+import type { InfernoNode } from 'inferno';
+import { useBackend } from '../../backend';
+import { Box, Flex, Tooltip, Input, Icon } from '../../components';
+import { PreferencesMenuData } from './data';
+import features from './preferences/features';
+import { FeatureValueInput } from './preferences/features/base';
+import { TabbedMenu } from './TabbedMenu';
+import { createSearch } from 'common/string';
+
+type PreferenceChild = {
+ name: string;
+ children: InfernoNode;
+};
+
+const binaryInsertPreference = binaryInsertWith((child) => child.name);
+
+export const GamePreferencesPage = (props, context) => {
+ const { act, data } = useBackend(context);
+ let [searchText, setSearchText] = useLocalState(context, 'game_prefs_searchText', '');
+
+ const gamePreferences: Record> = {};
+
+ for (const [featureId, value] of Object.entries(data.character_preferences.game_preferences)) {
+ const feature = features[featureId];
+
+ let nameInner: InfernoNode = feature?.name || featureId;
+
+ if (feature?.description) {
+ nameInner = (
+
+ {nameInner}
+
+ );
+ }
+
+ let name: InfernoNode = (
+
+ {nameInner}
+
+ );
+
+ if (feature?.description) {
+ name = (
+
+ {name}
+
+ );
+ }
+
+ const child = (
+
+
+ {name}
+
+
+ {(feature && ) || (
+
+ ...is not filled out properly!!!
+
+ )}
+
+
+ );
+
+ const entry = {
+ name: feature?.name || featureId,
+ children: child,
+ };
+
+ const category = feature?.category || 'ERROR';
+ const subcategory = feature?.subcategory || '';
+ const curCategory = gamePreferences[category] || [];
+ gamePreferences[category] = curCategory;
+
+ gamePreferences[category][subcategory] = binaryInsertPreference(curCategory[subcategory] || [], entry);
+ }
+
+ const sortByName = sortBy(([name]) => name);
+
+ const gamePreferenceEntries: [string, InfernoNode][] = sortByName(Object.entries(gamePreferences)).map(
+ ([category, subcategory]) => {
+ let subcategories = sortByName(Object.entries(subcategory));
+ return [
+ category,
+ <>
+ {subcategories.map(([subcategory, preferences], index) => (
+
+ {subcategory?.length ? (
+
+
+
+
+ {subcategory}
+
+
+
+
+
+ ) : null}
+ {preferences.map((preference) => preference.children)}
+
+ ))}
+ >,
+ ];
+ }
+ );
+
+ const sortByNameTyped = sortBy<[string, Record]>(([name]) => name);
+
+ const search = createSearch(searchText, (preference: PreferenceChild) => preference.name);
+ const searchResult: null | [string, InfernoNode][] =
+ searchText?.length > 0
+ ? [
+ [
+ 'Search Result',
+ sortByNameTyped(Object.entries(gamePreferences))
+ .flatMap(([category, categoryObj]) =>
+ Object.entries(categoryObj).map<[string, PreferenceChild[]]>(([k, v]) => [category + (k ? ' > ' + k : ''), v])
+ )
+ .filter(([_, preferences]) => preferences.some(search))
+ .map(([subcategory, preferences], index) => (
+
+ {subcategory?.length ? (
+
+
+
+
+ {subcategory}
+
+
+
+
+
+ ) : null}
+ {preferences.filter(search).map((preference) => preference.children)}
+
+ )),
+ ],
+ ]
+ : null;
+
+ const result: [string, InfernoNode][] = searchResult || gamePreferenceEntries;
+
+ return (
+
+
+
+
+
+
+ setSearchText(value)} />
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx
new file mode 100644
index 0000000000000..0ee2532a74498
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx
@@ -0,0 +1,349 @@
+import { sortBy } from 'common/collections';
+import { classes } from 'common/react';
+import type { Inferno, InfernoNode } from 'inferno';
+import { useBackend } from '../../backend';
+import { Box, Button, Dropdown, Stack, Flex, Tooltip } from '../../components';
+import { createSetPreference, Job, JoblessRole, JobPriority, PreferencesMenuData } from './data';
+import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
+
+const sortJobs = (entries: [string, Job][], head?: string) =>
+ sortBy<[string, Job]>(
+ ([key, _]) => (key === head ? -1 : 1),
+ ([key, _]) => key
+ )(entries);
+
+const PriorityButton = (props: { name: string; modifier?: string; enabled: boolean; onClick: () => void }) => {
+ const className = `PreferencesMenu__Jobs__departments__priority`;
+
+ return (
+
+
+
+ );
+};
+
+type CreateSetPriority = (priority: JobPriority | null) => () => void;
+
+const createSetPriorityCache: Record = {};
+
+const createCreateSetPriorityFromName = (context, jobName: string): CreateSetPriority => {
+ if (createSetPriorityCache[jobName] !== undefined) {
+ return createSetPriorityCache[jobName];
+ }
+
+ const perPriorityCache: Map void> = new Map();
+
+ const createSetPriority = (priority: JobPriority | null) => {
+ const existingCallback = perPriorityCache.get(priority);
+ if (existingCallback !== undefined) {
+ return existingCallback;
+ }
+
+ const setPriority = () => {
+ const { act } = useBackend(context);
+
+ act('set_job_preference', {
+ job: jobName,
+ level: priority,
+ });
+ };
+
+ perPriorityCache.set(priority, setPriority);
+ return setPriority;
+ };
+
+ createSetPriorityCache[jobName] = createSetPriority;
+
+ return createSetPriority;
+};
+
+const PriorityButtons = (props: { createSetPriority: CreateSetPriority; isOverflow: boolean; priority: JobPriority }) => {
+ const { createSetPriority, isOverflow, priority } = props;
+
+ return (
+
+ {isOverflow ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+const JobRow = (
+ props: {
+ className?: string;
+ job: Job;
+ name: string;
+ },
+ context
+) => {
+ const { data } = useBackend(context);
+ const { className, job, name } = props;
+
+ const isOverflow = data.overflow_role === name;
+ const priority = data.job_preferences[name];
+
+ const createSetPriority = createCreateSetPriorityFromName(context, name);
+
+ const experienceNeeded = data.job_required_experience && data.job_required_experience[name];
+ const daysLeft = data.job_days_left ? data.job_days_left[name] : 0;
+
+ let rightSide: InfernoNode;
+
+ if (experienceNeeded) {
+ const { experience_type, required_playtime } = experienceNeeded;
+ const hoursNeeded = Math.ceil(required_playtime / 60);
+
+ rightSide = (
+
+
+ {hoursNeeded}h as {experience_type}
+
+
+ );
+ } else if (daysLeft > 0) {
+ rightSide = (
+
+
+ {daysLeft} day{daysLeft === 1 ? '' : 's'} left
+
+
+ );
+ } else if (data.job_bans && data.job_bans.indexOf(name) !== -1) {
+ rightSide = (
+
+
+ Banned
+
+
+ );
+ } else {
+ rightSide = ;
+ }
+
+ return (
+
+
+
+
+ {name}
+
+
+
+
+ {rightSide}
+
+
+
+ );
+};
+
+const Department: Inferno.SFC<{ department: string }> = (props) => {
+ const { children, department: name } = props;
+ const className = `PreferencesMenu__Jobs__departments--${name}`;
+
+ return (
+ {
+ if (!data) {
+ return null;
+ }
+
+ const { departments, jobs } = data.jobs;
+ const department = departments[name];
+
+ // This isn't necessarily a bug, it's like this
+ // so that you can remove entire departments without
+ // having to edit the UI.
+ // This is used in events, for instance.
+ if (!department) {
+ return null;
+ }
+
+ const jobsForDepartment = sortJobs(
+ Object.entries(jobs).filter(([_, job]) => job.department === name),
+ department.head
+ );
+
+ return (
+
+
+ {jobsForDepartment.map(([name, job]) => {
+ return (
+
+ );
+ })}
+
+
+ {children}
+
+ );
+ }}
+ />
+ );
+};
+
+// *Please* find a better way to do this, this is RIDICULOUS.
+// All I want is for a gap to pretend to be an empty space.
+// But in order for everything to align, I also need to add the 0.2em padding.
+// But also, we can't be aligned with names that break into multiple lines!
+const Gap = (props: { amount: number }) => {
+ // 0.2em comes from the padding-bottom in the department listing
+ return ;
+};
+
+const JoblessRoleDropdown = (props, context) => {
+ const { act, data } = useBackend(context);
+ const selected = data.character_preferences.misc.joblessrole;
+
+ const options = [
+ {
+ displayText: `Join as ${data.overflow_role} if unavailable`,
+ value: JoblessRole.BeOverflow,
+ },
+ {
+ displayText: `Join as a random job if unavailable`,
+ value: JoblessRole.BeRandomJob,
+ },
+ {
+ displayText: `Return to lobby if unavailable`,
+ value: JoblessRole.ReturnToLobby,
+ },
+ ];
+
+ return (
+
+ {options.find((option) => option.value === selected)!.displayText}}
+ />
+
+ );
+};
+
+const ClearJobsButton = (_, context) => {
+ const { act } = useBackend(context);
+ return act('clear_job_preferences')} />;
+};
+
+export const JobsPage = () => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/KeybindingsPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/KeybindingsPage.tsx
new file mode 100644
index 0000000000000..350ae46e2a9e8
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/KeybindingsPage.tsx
@@ -0,0 +1,468 @@
+import { Component } from 'inferno';
+import { Box, Button, KeyListener, Stack, Tooltip, TrackOutsideClicks, Dimmer } from '../../components';
+import { resolveAsset } from '../../assets';
+import { PreferencesMenuData } from './data';
+import { useBackend } from '../../backend';
+import { range, sortBy } from 'common/collections';
+import { KeyEvent } from '../../events';
+import { TabbedMenu } from './TabbedMenu';
+import { fetchRetry } from '../../http';
+
+type Keybinding = {
+ name: string;
+ description?: string;
+};
+
+type Keybindings = Record>;
+
+type KeybindingsPageState = {
+ keybindings?: Keybindings;
+ lastKeyboardEvent?: KeyboardEvent;
+ selectedKeybindings?: PreferencesMenuData['keybindings'];
+ error: any;
+ /**
+ * The current hotkey that the user is rebinding.
+ *
+ * First element is the hotkey name, the second is the slot.
+ */
+ rebindingHotkey?: [string, number];
+};
+
+const isStandardKey = (event: KeyboardEvent): boolean => {
+ return event.key !== 'Alt' && event.key !== 'Control' && event.key !== 'Shift' && event.key !== 'Esc';
+};
+
+const KEY_CODE_TO_BYOND: Record = {
+ 'DEL': 'Delete',
+ 'DOWN': 'South',
+ 'END': 'Southwest',
+ 'HOME': 'Northwest',
+ 'INSERT': 'Insert',
+ 'LEFT': 'West',
+ 'PAGEDOWN': 'Southeast',
+ 'PAGEUP': 'Northeast',
+ 'RIGHT': 'East',
+ 'SPACEBAR': 'Space',
+ 'UP': 'North',
+};
+
+/**
+ * So, as it turns out, KeyboardEvent seems to be broken with IE 11, the
+ * DOM_KEY_LOCATION_X codes are all undefined. See this to see why it's 3:
+ * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/location
+ */
+const DOM_KEY_LOCATION_NUMPAD = 3;
+
+const sortKeybindings = sortBy(([_, keybinding]: [string, Keybinding]) => {
+ return keybinding.name;
+});
+
+const sortKeybindingsByCategory = sortBy(([category, _]: [string, Record]) => {
+ return category;
+});
+
+const formatKeyboardEvent = (event: KeyboardEvent): string => {
+ let text = '';
+
+ if (event.altKey) {
+ text += 'Alt';
+ }
+
+ if (event.ctrlKey) {
+ text += 'Ctrl';
+ }
+
+ if (event.shiftKey) {
+ text += 'Shift';
+ }
+
+ if (event.location === DOM_KEY_LOCATION_NUMPAD) {
+ text += 'Numpad';
+ }
+
+ if (isStandardKey(event)) {
+ const key = event.key.toUpperCase();
+ text += KEY_CODE_TO_BYOND[key] || key;
+ }
+
+ return text;
+};
+
+const moveToBottom = (entries: [string, unknown][], findCategory: string) => {
+ entries.push(
+ entries.splice(
+ entries.findIndex(([category, _]) => {
+ return category === findCategory;
+ }),
+ 1
+ )[0]
+ );
+};
+
+class KeybindingButton extends Component<{
+ currentHotkey?: string;
+ onClick?: () => void;
+ typingHotkey?: string;
+}> {
+ shouldComponentUpdate(nextProps) {
+ return this.props.typingHotkey !== nextProps.typingHotkey || this.props.currentHotkey !== nextProps.currentHotkey;
+ }
+
+ render() {
+ const { currentHotkey, onClick, typingHotkey } = this.props;
+
+ const child = (
+
+ {typingHotkey || currentHotkey || 'Unbound'}
+
+ );
+
+ if (typingHotkey && onClick) {
+ return (
+ // onClick will cancel it
+
+ {child}
+
+ );
+ } else {
+ return child;
+ }
+ }
+}
+
+const KeybindingName = (props: { keybinding: Keybinding }) => {
+ const { keybinding } = props;
+
+ return keybinding.description ? (
+
+
+ {keybinding.name}
+
+
+ ) : (
+ {keybinding.name}
+ );
+};
+
+KeybindingName.defaultHooks = {
+ onComponentShouldUpdate: (lastProps, nextProps) => {
+ return lastProps.keybinding !== nextProps.keybinding;
+ },
+};
+
+const ResetToDefaultButton = (
+ props: {
+ keybindingId: string;
+ },
+ context
+) => {
+ const { act } = useBackend(context);
+
+ return (
+ {
+ act('reset_keybinds_to_defaults', {
+ keybind_name: props.keybindingId,
+ });
+ }}>
+ Reset to Defaults
+
+ );
+};
+
+export class KeybindingsPage extends Component<{}, KeybindingsPageState> {
+ cancelNextKeyUp?: number;
+ keybindingOnClicks: Record void)[]> = {};
+ lastKeybinds?: PreferencesMenuData['keybindings'];
+ state: KeybindingsPageState = {
+ lastKeyboardEvent: undefined,
+ keybindings: undefined,
+ selectedKeybindings: undefined,
+ rebindingHotkey: undefined,
+ error: false,
+ };
+
+ constructor() {
+ super();
+
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.handleKeyUp = this.handleKeyUp.bind(this);
+ }
+
+ componentDidMount() {
+ this.populateSelectedKeybindings();
+ this.populateKeybindings();
+ }
+
+ componentDidUpdate() {
+ const { data } = useBackend(this.context);
+
+ // keybindings is static data, so it'll pass `===` checks.
+ // This'll change when resetting to defaults.
+ if (data.keybindings !== this.lastKeybinds) {
+ this.populateSelectedKeybindings();
+ }
+ }
+
+ setRebindingHotkey(value?: string) {
+ const { act } = useBackend(this.context);
+
+ this.setState((state) => {
+ let selectedKeybindings = state.selectedKeybindings;
+ if (!selectedKeybindings) {
+ return state;
+ }
+
+ if (!state.rebindingHotkey) {
+ return state;
+ }
+
+ selectedKeybindings = { ...selectedKeybindings };
+
+ const [keybindName, slot] = state.rebindingHotkey;
+
+ if (selectedKeybindings[keybindName]) {
+ if (value) {
+ selectedKeybindings[keybindName][Math.min(selectedKeybindings[keybindName].length, slot)] = value;
+ } else {
+ selectedKeybindings[keybindName].splice(slot, 1);
+ }
+ } else if (!value) {
+ return state;
+ } else {
+ selectedKeybindings[keybindName] = [value];
+ }
+
+ act('set_keybindings', {
+ 'keybind_name': keybindName,
+ 'hotkeys': selectedKeybindings[keybindName],
+ });
+
+ return {
+ lastKeyboardEvent: undefined,
+ rebindingHotkey: undefined,
+ error: false,
+ selectedKeybindings,
+ };
+ });
+ }
+
+ handleKeyDown(keyEvent: KeyEvent) {
+ const event = keyEvent.event;
+ const rebindingHotkey = this.state?.rebindingHotkey;
+
+ if (!rebindingHotkey) {
+ return;
+ }
+
+ event.preventDefault();
+
+ this.cancelNextKeyUp = keyEvent.code;
+
+ if (isStandardKey(event)) {
+ this.setRebindingHotkey(formatKeyboardEvent(event));
+ return;
+ } else if (event.key === 'Esc') {
+ this.setRebindingHotkey(undefined);
+ return;
+ }
+
+ this.setState({
+ lastKeyboardEvent: event,
+ });
+ }
+
+ handleKeyUp(keyEvent: KeyEvent) {
+ if (this.cancelNextKeyUp === keyEvent.code) {
+ this.cancelNextKeyUp = undefined;
+ keyEvent.event.preventDefault();
+ }
+
+ if (this.state === null) {
+ return;
+ }
+
+ const { lastKeyboardEvent, rebindingHotkey } = this.state;
+
+ if (rebindingHotkey && lastKeyboardEvent) {
+ this.setRebindingHotkey(formatKeyboardEvent(lastKeyboardEvent));
+ }
+ }
+
+ getKeybindingOnClick(keybindingId: string, slot: number): () => void {
+ if (!this.keybindingOnClicks[keybindingId]) {
+ this.keybindingOnClicks[keybindingId] = [];
+ }
+
+ if (!this.keybindingOnClicks[keybindingId][slot]) {
+ this.keybindingOnClicks[keybindingId][slot] = () => {
+ if (this.state?.rebindingHotkey === undefined) {
+ this.setState({
+ lastKeyboardEvent: undefined,
+ rebindingHotkey: [keybindingId, slot],
+ });
+ } else {
+ this.setState({
+ lastKeyboardEvent: undefined,
+ rebindingHotkey: undefined,
+ });
+ }
+ };
+ }
+
+ return this.keybindingOnClicks[keybindingId][slot];
+ }
+
+ getTypingHotkey(keybindingId: string, slot: number): string | undefined {
+ if (!this.state) {
+ return;
+ }
+ const { lastKeyboardEvent, rebindingHotkey } = this.state;
+
+ if (!rebindingHotkey) {
+ return undefined;
+ }
+
+ if (rebindingHotkey[0] !== keybindingId || rebindingHotkey[1] !== slot) {
+ return undefined;
+ }
+
+ if (lastKeyboardEvent === undefined) {
+ return '...';
+ }
+
+ return formatKeyboardEvent(lastKeyboardEvent);
+ }
+
+ async populateKeybindings() {
+ const keybindingsResponse = await fetchRetry(resolveAsset('keybindings.json'))
+ .then((response) => response.json())
+ .catch((err) => {
+ this.setState({
+ error: err,
+ });
+ });
+ const keybindingsData: Keybindings = await keybindingsResponse;
+
+ this.setState({
+ keybindings: keybindingsData,
+ });
+ }
+
+ populateSelectedKeybindings() {
+ const { data } = useBackend(this.context);
+
+ this.lastKeybinds = data.keybindings;
+
+ this.setState({
+ selectedKeybindings: Object.fromEntries(
+ Object.entries(data.keybindings).map(([keybind, hotkeys]) => {
+ return [keybind, hotkeys.filter((value) => value !== 'Unbound')];
+ })
+ ),
+ });
+ }
+
+ render() {
+ if (this.state && this.state.error !== false) {
+ return (
+
+ Error: Unable to fetch keybinding data.
+
+ Contact a maintainer or create an issue report by pressing Report Issue in the top right of the game window.
+
+
+ Error Details:{'\n'}
+ {typeof this.state.error === 'object' && Object.keys(this.state.error).includes('stack')
+ ? this.state.error.stack
+ : this.state.error.toString()}
+
+
+ );
+ }
+ const { act } = useBackend(this.context);
+ const keybindings = this.state?.keybindings;
+
+ if (!keybindings) {
+ return Loading keybindings...;
+ }
+
+ const keybindingEntries = sortKeybindingsByCategory(Object.entries(keybindings));
+
+ moveToBottom(keybindingEntries, 'EMOTE');
+ moveToBottom(keybindingEntries, 'ADMIN');
+
+ return (
+ <>
+
+
+
+
+ {
+ return [
+ category,
+
+ {sortKeybindings(Object.entries(keybindings)).map(([keybindingId, keybinding]) => {
+ const keys = this.state.selectedKeybindings![keybindingId] || [];
+
+ const name = (
+
+
+
+ );
+
+ return (
+
+
+ {name}
+
+ {range(0, 3).map((key) => (
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+ })}
+ ,
+ ];
+ })}
+ />
+
+
+
+ act('reset_all_keybinds')} />
+
+
+ >
+ );
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/LoadoutPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/LoadoutPage.tsx
new file mode 100644
index 0000000000000..380645842b35b
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/LoadoutPage.tsx
@@ -0,0 +1,255 @@
+import { Box, Tabs, Button, Tooltip, Stack, Flex, Table, Section, Icon, Input } from '../../components';
+import { LoadoutGear, PreferencesMenuData } from './data';
+import { useBackend, useLocalState } from '../../backend';
+import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
+import { CharacterPreview } from './CharacterPreview';
+import { createSearch } from 'common/string';
+
+const isPurchased = (purchased_gear: string[], gear: LoadoutGear) => purchased_gear.includes(gear.id) && !gear.multi_purchase;
+
+export const LoadoutPage = (props, context) => {
+ const { act, data } = useBackend(context);
+ const { purchased_gear = [], metacurrency_balance = 0, is_donator = false } = data;
+
+ return (
+ {
+ if (!serverData) {
+ return Loading loadout data...;
+ }
+ const { categories = [], metacurrency_name } = serverData.loadout;
+ const [selectedCategory, setSelectedCategory] = useLocalState(context, 'category', categories[0].name);
+ let [searchText, setSearchText] = useLocalState(context, 'loadout_search', '');
+ let search = createSearch(searchText, (gear: LoadoutGear) => {
+ return gear.display_name + ' ' + gear.skirt_display_name + ' ' + gear.allowed_roles?.join(' ');
+ });
+
+ let selectedCategoryObject = categories.filter((c) => c.name === selectedCategory)[0];
+ let currency_text = metacurrency_balance.toLocaleString() + ' ' + metacurrency_name + 's';
+ const showRoles =
+ !selectedCategoryObject || selectedCategoryObject.gear.filter((g) => g.allowed_roles?.length).length > 0;
+
+ return (
+
+
+
+
+ act('rotate')} />
+
+
+ {currency_text}
+
+
+
+
+
+
+
+
+
+ {!searchText?.length && (
+
+
+ {categories
+ .filter((c) => c.name !== 'Donator' || is_donator)
+ .map((category) => (
+ setSelectedCategory(category.name)}>
+ {category.name}
+
+ ))}
+ setSelectedCategory('Purchased')}>
+ Purchased
+
+
+
+ )}
+
+
+
+
+
+ Name
+ {showRoles || searchText.length ? Roles : null}
+ {selectedCategory !== 'Donator' && (
+
+ Cost
+
+ )}
+
+
+ {!searchText?.length && selectedCategoryObject
+ ? selectedCategoryObject.gear.map((gear) => (
+
+ ))
+ : null}
+ {searchText.length || selectedCategory === 'Purchased'
+ ? categories
+ .flatMap((c) => c.gear)
+ .filter((gear) => (searchText.length ? search(gear) : isPurchased(purchased_gear, gear)))
+ .map((gear) => (
+
+ ))
+ : null}
+
+
+
+
+
+
+ );
+ }}
+ />
+ );
+};
+
+const GearEntry = (
+ props: { gear: LoadoutGear; metacurrency_name: string; selectedCategory: string; showRoles?: boolean },
+ context
+) => {
+ const { act, data } = useBackend(context);
+ const { equipped_gear = [], purchased_gear = [], metacurrency_balance, character_preferences, is_donator = false } = data;
+ const { gear, metacurrency_name, selectedCategory, showRoles = true } = props;
+ const jumpsuit_style = character_preferences.clothing.jumpsuit_style;
+
+ return (
+
+
+
+
+
+ {gear.description || gear.skirt_description ? (
+
+
+ {jumpsuit_style === 'Jumpskirt' && gear.skirt_display_name ? gear.skirt_display_name : gear.display_name}
+
+
+ ) : (
+
+ {jumpsuit_style === 'Jumpskirt' && gear.skirt_display_name ? gear.skirt_display_name : gear.display_name}
+
+ )}
+
+ {showRoles && (
+
+ {gear.allowed_roles && gear.allowed_roles.length > 0 ? (
+ gear.allowed_roles.length === 1 ? (
+ gear.allowed_roles[0]
+ ) : (
+
+
+ {gear.allowed_roles[0]}, {gear.allowed_roles[1][0]}...
+
+
+ )
+ ) : null}
+
+ )}
+ {selectedCategory !== 'Donator' && (
+
+ {gear.cost.toLocaleString()}
+
+ )}
+
+ metacurrency_balance) ||
+ (gear.donator && !is_donator) ||
+ (isPurchased(purchased_gear, gear) && !gear.is_equippable && !gear.multi_purchase)
+ }
+ tooltip={
+ !isPurchased(purchased_gear, gear) && gear.cost > metacurrency_balance
+ ? 'Not Enough ' + metacurrency_name + 's!'
+ : null
+ }
+ content={
+ isPurchased(purchased_gear, gear)
+ ? equipped_gear.includes(gear.id)
+ ? 'Unequip'
+ : !gear.is_equippable
+ ? 'Purchased'
+ : 'Equip'
+ : 'Purchase'
+ }
+ onClick={() =>
+ act(isPurchased(purchased_gear, gear) ? 'equip_gear' : 'purchase_gear', {
+ id: gear.id,
+ })
+ }
+ />
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx
new file mode 100644
index 0000000000000..41bfcf24b6dbe
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx
@@ -0,0 +1,630 @@
+import { classes } from 'common/react';
+import { sendAct, useBackend, useLocalState } from '../../backend';
+import { Box, Button, Flex, LabeledList, Popper, Stack, TrackOutsideClicks, Input, Icon, FitText } from '../../components';
+import { createSetPreference, PreferencesMenuData, RandomSetting } from './data';
+import { CharacterPreview } from './CharacterPreview';
+import { RandomizationButton } from './RandomizationButton';
+import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
+import { MultiNameInput, NameInput } from './names';
+import { Gender, GENDERS } from './preferences/gender';
+import features from './preferences/features';
+import { FeatureChoicedServerData, FeatureValueInput } from './preferences/features/base';
+import { filterMap, sortBy } from 'common/collections';
+import { useRandomToggleState } from './useRandomToggleState';
+import { createSearch } from 'common/string';
+
+const CLOTHING_CELL_SIZE = 64;
+const CLOTHING_SIDEBAR_ROWS = 10;
+
+const CLOTHING_SELECTION_CELL_SIZE = 64;
+const CLOTHING_SELECTION_CELL_SIZE_HORIZONTAL = 84;
+const CLOTHING_SELECTION_CELL_SIZE_VERTICAL = 135;
+const ENTRIES_PER_ROW = 5;
+const MAX_ROWS = 2.8;
+
+const CharacterControls = (props: {
+ handleRotate: (direction: boolean) => void;
+ handleOpenSpecies: () => void;
+ gender: Gender;
+ setGender: (gender: Gender) => void;
+ showGender: boolean;
+}) => {
+ return (
+
+
+ props.handleRotate(true)}
+ fontSize="22px"
+ icon="undo"
+ tooltip="Rotate -90°"
+ tooltipPosition="top"
+ />
+
+
+ props.handleRotate(false)}
+ fontSize="22px"
+ icon="redo"
+ tooltip="Rotate 90°"
+ tooltipPosition="top"
+ />
+
+
+
+
+
+
+
+
+ {props.showGender && (
+
+
+
+ )}
+
+ );
+};
+
+const ChoicedSelection = (
+ props: {
+ name: string;
+ catalog: FeatureChoicedServerData;
+ selected: string;
+ supplementalFeature?: string;
+ supplementalValue?: unknown;
+ onClose: () => void;
+ onSelect: (value: string) => void;
+ searchText: string;
+ setSearchText: (value: string) => void;
+ },
+ context
+) => {
+ const { act } = useBackend(context);
+
+ const { catalog, supplementalFeature, supplementalValue, searchText, setSearchText } = props;
+
+ if (!catalog.icons) {
+ return Provided catalog had no icons!;
+ }
+
+ let search = createSearch(searchText, (name: string) => {
+ return name;
+ });
+
+ const use_small_supplemental =
+ supplementalFeature &&
+ (features[supplementalFeature].small_supplemental === true ||
+ features[supplementalFeature].small_supplemental === undefined);
+
+ const entryCount = Object.keys(catalog.icons).length;
+
+ const calculatedWidth = CLOTHING_SELECTION_CELL_SIZE_HORIZONTAL * Math.min(entryCount, ENTRIES_PER_ROW);
+ const baseHeight = CLOTHING_SELECTION_CELL_SIZE_VERTICAL * Math.min(Math.ceil(entryCount / ENTRIES_PER_ROW), MAX_ROWS);
+ const calculatedHeight = baseHeight + (supplementalFeature && !use_small_supplemental ? 100 : 0);
+
+ return (
+
+
+
+
+
+ {supplementalFeature && use_small_supplemental && (
+
+
+
+ )}
+
+
+
+ Select {props.name}
+
+
+
+
+
+ X
+
+
+
+
+
+ {Object.keys(catalog.icons).length > 5 && (
+
+
+
+ setSearchText(value)}
+ />
+
+
+ )}
+
+
+
+ {Object.entries(catalog.icons)
+ .filter(([n, _]) => searchText?.length < 1 || search(n))
+ .map(([name, image], index) => {
+ return (
+
+ {
+ props.onSelect(name);
+ }}
+ selected={name === props.selected}
+ style={{
+ height: `${CLOTHING_SELECTION_CELL_SIZE}px`,
+ width: `${CLOTHING_SELECTION_CELL_SIZE}px`,
+ }}>
+
+
+
+
+ {name}
+
+
+
+ );
+ })}
+
+
+ {supplementalFeature && !use_small_supplemental && (
+ <>
+
+
+ Select {features[supplementalFeature].name}
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+const GenderButton = (
+ props: {
+ handleSetGender: (gender: Gender) => void;
+ gender: Gender;
+ },
+ context
+) => {
+ const [genderMenuOpen, setGenderMenuOpen] = useLocalState(context, 'genderMenuOpen', false);
+
+ return (
+ setGenderMenuOpen(false)} removeOnOutsideClick>
+
+
+ {[Gender.Male, Gender.Female, Gender.Other].map((gender) => {
+ return (
+
+ {
+ props.handleSetGender(gender);
+ setGenderMenuOpen(false);
+ }}
+ fontSize="22px"
+ icon={GENDERS[gender].icon}
+ tooltip={GENDERS[gender].text}
+ tooltipPosition="top"
+ />
+
+ );
+ })}
+
+
+
+ )
+ }>
+ {
+ setGenderMenuOpen(!genderMenuOpen);
+ }}
+ fontSize="22px"
+ icon={GENDERS[props.gender].icon}
+ tooltip="Gender"
+ tooltipPosition="top"
+ />
+
+ );
+};
+
+const MainFeature = (
+ props: {
+ catalog: FeatureChoicedServerData & {
+ name: string;
+ supplemental_feature?: string;
+ };
+ currentValue: string;
+ isOpen: boolean;
+ handleClose: () => void;
+ handleOpen: () => void;
+ handleSelect: (newClothing: string) => void;
+ randomization?: RandomSetting;
+ setRandomization: (newSetting: RandomSetting) => void;
+ },
+ context
+) => {
+ const { act, data } = useBackend(context);
+
+ const { catalog, currentValue, isOpen, handleOpen, handleClose, handleSelect, randomization, setRandomization } = props;
+
+ const supplementalFeature = catalog.supplemental_feature;
+ let [searchText, setSearchText] = useLocalState(context, catalog.name + '_choiced_search', '');
+ const handleCloseInternal = () => {
+ handleClose();
+ setSearchText('');
+ };
+
+ return (
+
+
+
+ )
+ }>
+ {
+ if (isOpen) {
+ handleCloseInternal();
+ } else {
+ handleOpen();
+ }
+ }}
+ style={{
+ height: `${CLOTHING_CELL_SIZE}px`,
+ width: `${CLOTHING_CELL_SIZE}px`,
+ }}
+ position="relative"
+ tooltip={catalog.name}
+ tooltipPosition="right">
+
+
+ {randomization && (
+ {
+ // We're a button inside a button.
+ // Did you know that's against the W3C standard? :)
+ event.cancelBubble = true;
+ event.stopPropagation();
+ },
+ }}
+ value={randomization}
+ setValue={setRandomization}
+ />
+ )}
+
+
+ {catalog.name}
+
+
+ );
+};
+
+const createSetRandomization = (act: typeof sendAct, preference: string) => (newSetting: RandomSetting) => {
+ act('set_random_preference', {
+ preference,
+ value: newSetting,
+ });
+};
+
+const sortPreferences = sortBy<[string, unknown]>(([featureId, _]) => {
+ const feature = features[featureId];
+ return feature?.name;
+});
+
+const PreferenceList = (props: {
+ act: typeof sendAct;
+ preferences: Record;
+ randomizations: Record;
+}) => {
+ return (
+
+
+ {sortPreferences(Object.entries(props.preferences)).map(([featureId, value]) => {
+ const feature = features[featureId];
+ const randomSetting = props.randomizations[featureId];
+
+ if (feature === undefined) {
+ return (
+
+ Feature {featureId} is not recognized.
+
+ );
+ }
+
+ return (
+
+
+ {randomSetting && (
+
+
+
+ )}
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export const MainPage = (
+ props: {
+ openSpecies: () => void;
+ },
+ context
+) => {
+ const { act, data } = useBackend(context);
+ const [currentClothingMenu, setCurrentClothingMenu] = useLocalState(context, 'currentClothingMenu', null);
+ const [multiNameInputOpen, setMultiNameInputOpen] = useLocalState(context, 'multiNameInputOpen', false);
+ const [randomToggleEnabled] = useRandomToggleState(context);
+
+ return (
+ {
+ const currentSpeciesData = serverData && serverData.species[data.character_preferences.misc.species];
+
+ const contextualPreferences = data.character_preferences.secondary_features || [];
+
+ const mainFeatures = [
+ ...Object.entries(data.character_preferences.clothing),
+ ...Object.entries(data.character_preferences.features).filter(([featureName]) => {
+ if (!currentSpeciesData) {
+ return false;
+ }
+
+ return currentSpeciesData.enabled_features.indexOf(featureName) !== -1;
+ }),
+ ];
+
+ const randomBodyEnabled =
+ data.character_preferences.non_contextual.body_is_always_random !== RandomSetting.Disabled || randomToggleEnabled;
+
+ const getRandomization = (preferences: Record): Record => {
+ if (!serverData) {
+ return {};
+ }
+
+ return Object.fromEntries(
+ filterMap(Object.keys(preferences), (preferenceKey) => {
+ if (serverData.random.randomizable.indexOf(preferenceKey) === -1) {
+ return undefined;
+ }
+
+ if (!randomBodyEnabled) {
+ return undefined;
+ }
+
+ return [preferenceKey, data.character_preferences.randomization[preferenceKey] || RandomSetting.Disabled];
+ })
+ );
+ };
+
+ const randomizationOfMainFeatures = getRandomization(Object.fromEntries(mainFeatures));
+
+ const nonContextualPreferences = {
+ ...data.character_preferences.non_contextual,
+ };
+
+ if (randomBodyEnabled) {
+ nonContextualPreferences['random_species'] = data.character_preferences.randomization['species'];
+ } else {
+ // We can't use random_name/is_accessible because the
+ // server doesn't know whether the random toggle is on.
+ delete nonContextualPreferences['name_is_always_random'];
+ }
+
+ return (
+ <>
+ {multiNameInputOpen && (
+ setMultiNameInputOpen(false)}
+ handleRandomizeName={(preference) =>
+ act('randomize_name', {
+ preference,
+ })
+ }
+ handleUpdateName={(nameType, value) =>
+ act('set_preference', {
+ preference: nameType,
+ value,
+ })
+ }
+ names={data.character_preferences.names}
+ />
+ )}
+
+
+
+
+
+ {
+ act('rotate', { direction: direction });
+ }}
+ setGender={createSetPreference(act, 'gender')}
+ showGender={currentSpeciesData ? !!currentSpeciesData.sexes : true}
+ />
+
+
+
+
+
+
+
+ {
+ setMultiNameInputOpen(true);
+ }}
+ />
+
+
+
+
+
+
+ {mainFeatures.map(([clothingKey, clothing]) => {
+ const catalog =
+ serverData &&
+ (serverData[clothingKey] as FeatureChoicedServerData & {
+ name: string;
+ });
+
+ return (
+ catalog && (
+
+ {
+ setCurrentClothingMenu(null);
+ }}
+ handleOpen={() => {
+ setCurrentClothingMenu(clothingKey);
+ }}
+ handleSelect={createSetPreference(act, clothingKey)}
+ randomization={randomizationOfMainFeatures[clothingKey]}
+ setRandomization={createSetRandomization(act, clothingKey)}
+ />
+
+ )
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ }}
+ />
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/PageButton.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/PageButton.tsx
new file mode 100644
index 0000000000000..19e3f0ce58a41
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/PageButton.tsx
@@ -0,0 +1,21 @@
+import type { InfernoNode } from 'inferno';
+import { Button } from '../../components';
+
+export const PageButton = (props: {
+ currentPage: P;
+ page: P;
+ otherActivePages?: P[];
+
+ setPage: (page: P) => void;
+
+ children?: InfernoNode;
+}) => {
+ const pageIsActive =
+ props.currentPage === props.page || (props.otherActivePages && props.otherActivePages.indexOf(props.currentPage) !== -1);
+
+ return (
+ props.setPage(props.page)}>
+ {props.children}
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/QuirksPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/QuirksPage.tsx
new file mode 100644
index 0000000000000..ba10deaf2c164
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/QuirksPage.tsx
@@ -0,0 +1,316 @@
+import type { Inferno } from 'inferno';
+import { Box, Icon, Stack, Tooltip } from '../../components';
+import { PreferencesMenuData, Quirk } from './data';
+import { useBackend, useLocalState } from '../../backend';
+import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
+import { logger } from 'tgui/logging';
+
+const getValueClass = (value: number): string => {
+ if (value > 0) {
+ return 'positive';
+ } else if (value < 0) {
+ return 'negative';
+ } else {
+ return 'neutral';
+ }
+};
+
+const QuirkList = (props: {
+ quirks: [
+ string,
+ Quirk & {
+ failTooltip?: string;
+ }
+ ][];
+ onClick: (quirkName: string, quirk: Quirk) => void;
+}) => {
+ return (
+ // Stack is not used here for a variety of IE flex bugs
+
+ {props.quirks.map(([quirkKey, quirk]) => {
+ const className = 'PreferencesMenu__Quirks__QuirkList__quirk';
+ if (!quirk.icon) {
+ logger.info(quirk.name);
+ }
+
+ const child = (
+ {
+ props.onClick(quirkKey, quirk);
+ }}>
+
+
+ {quirk.icon && }
+
+
+
+
+
+
+
+
+
+ {quirk.name}
+
+
+
+ {quirk.value}
+
+
+
+
+
+ {quirk.description}
+
+
+
+
+
+ );
+
+ if (quirk.failTooltip) {
+ return (
+
+ {child}
+
+ );
+ } else {
+ return child;
+ }
+ })}
+
+ );
+};
+
+const StatDisplay: Inferno.StatelessComponent<{}> = (props) => {
+ return (
+
+ {props.children}
+
+ );
+};
+
+export const QuirksPage = (props, context) => {
+ const { act, data } = useBackend(context);
+
+ const [selectedQuirks, setSelectedQuirks] = useLocalState(
+ context,
+ `selectedQuirks_${data.active_slot}`,
+ data.selected_quirks
+ );
+
+ return (
+ {
+ if (!data) {
+ return Loading quirks...;
+ }
+
+ const { max_positive_quirks: maxPositiveQuirks, quirk_blacklist: quirkBlacklist, quirk_info: quirkInfo } = data.quirks;
+
+ const quirks = Object.entries(quirkInfo);
+ quirks.sort(([_, quirkA], [__, quirkB]) => {
+ if (quirkA.value === quirkB.value) {
+ return quirkA.name > quirkB.name ? 1 : -1;
+ } else {
+ return quirkA.value - quirkB.value;
+ }
+ });
+
+ let balance = 0;
+ let positiveQuirks = 0;
+
+ for (const selectedQuirkName of selectedQuirks) {
+ const selectedQuirk = quirkInfo[selectedQuirkName];
+ if (!selectedQuirk) {
+ continue;
+ }
+
+ if (selectedQuirk.value > 0) {
+ positiveQuirks += 1;
+ }
+
+ balance += selectedQuirk.value;
+ }
+
+ const getReasonToNotAdd = (quirkName: string) => {
+ const quirk = quirkInfo[quirkName];
+
+ if (quirk.value > 0) {
+ if (positiveQuirks >= maxPositiveQuirks) {
+ return "You can't have any more positive quirks!";
+ } else if (balance + quirk.value > 0) {
+ return 'You need a negative quirk to balance this out!';
+ }
+ }
+
+ const selectedQuirkNames = selectedQuirks.map((quirkKey) => {
+ return quirkInfo[quirkKey].name;
+ });
+
+ for (const blacklist of quirkBlacklist) {
+ if (blacklist.indexOf(quirk.name) === -1) {
+ continue;
+ }
+
+ for (const incompatibleQuirk of blacklist) {
+ if (incompatibleQuirk !== quirk.name && selectedQuirkNames.indexOf(incompatibleQuirk) !== -1) {
+ return `This is incompatible with ${incompatibleQuirk}!`;
+ }
+ }
+ }
+
+ return undefined;
+ };
+
+ const getReasonToNotRemove = (quirkName: string) => {
+ const quirk = quirkInfo[quirkName];
+
+ if (balance - quirk.value > 0) {
+ return 'You need to remove a positive quirk first!';
+ }
+
+ return undefined;
+ };
+
+ return (
+
+
+
+
+ Positive Quirks
+
+
+
+
+ {positiveQuirks} / {maxPositiveQuirks}
+
+
+
+
+
+ Available Quirks
+
+
+
+
+ {
+ if (getReasonToNotAdd(quirkName) !== undefined) {
+ return;
+ }
+
+ setSelectedQuirks(selectedQuirks.concat(quirkName));
+
+ act('give_quirk', { quirk: quirk.name });
+ }}
+ quirks={quirks
+ .filter(([quirkName, _]) => {
+ return selectedQuirks.indexOf(quirkName) === -1;
+ })
+ .map(([quirkName, quirk]) => {
+ return [
+ quirkName,
+ {
+ ...quirk,
+ failTooltip: getReasonToNotAdd(quirkName),
+ },
+ ];
+ })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ Quirk Balance
+
+
+
+ {balance}
+
+
+
+
+ Current Quirks
+
+
+
+
+ {
+ if (getReasonToNotRemove(quirkName) !== undefined) {
+ return;
+ }
+
+ setSelectedQuirks(selectedQuirks.filter((otherQuirk) => quirkName !== otherQuirk));
+
+ act('remove_quirk', { quirk: quirk.name });
+ }}
+ quirks={quirks
+ .filter(([quirkName, _]) => {
+ return selectedQuirks.indexOf(quirkName) !== -1;
+ })
+ .map(([quirkName, quirk]) => {
+ return [
+ quirkName,
+ {
+ ...quirk,
+ failTooltip: getReasonToNotRemove(quirkName),
+ },
+ ];
+ })}
+ />
+
+
+
+
+ );
+ }}
+ />
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx
new file mode 100644
index 0000000000000..fb8749f83beb1
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx
@@ -0,0 +1,53 @@
+import { Dropdown, Icon } from '../../components';
+import { RandomSetting } from './data';
+
+export const RandomizationButton = (props: {
+ dropdownProps?: Record;
+ setValue: (newValue: RandomSetting) => void;
+ value?: RandomSetting;
+}) => {
+ const { dropdownProps = {}, setValue, value } = props;
+
+ let color;
+
+ switch (value) {
+ case RandomSetting.AntagOnly:
+ color = 'orange';
+ break;
+ case RandomSetting.Disabled:
+ color = 'red';
+ break;
+ case RandomSetting.Enabled:
+ color = 'green';
+ break;
+ }
+
+ return (
+ }
+ options={[
+ {
+ displayText: 'Do not randomize',
+ value: RandomSetting.Disabled,
+ },
+
+ {
+ displayText: 'Always randomize',
+ value: RandomSetting.Enabled,
+ },
+
+ {
+ displayText: 'Randomize when antagonist',
+ value: RandomSetting.AntagOnly,
+ },
+ ]}
+ nochevron
+ onSelected={setValue}
+ menuWidth="120px"
+ width="auto"
+ />
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/SaveStatus.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/SaveStatus.tsx
new file mode 100644
index 0000000000000..b4c4cf3e9f6df
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/SaveStatus.tsx
@@ -0,0 +1,64 @@
+import { Box, Tooltip } from '../../components';
+import { PreferencesMenuData } from './data';
+import { useBackend } from '../../backend';
+
+export const SaveStatus = (props, context) => {
+ const { data } = useBackend(context);
+ const { save_in_progress = false, is_db = true, is_guest = false, save_sucess = true } = data;
+ const innerBox = (
+
+ {!is_db ? No DB : is_guest ? Guest : null}
+ {!is_guest && is_db ? (
+ save_in_progress ? (
+
+ Saving
+ .
+ .
+ .
+
+ ) : (
+ {save_sucess ? 'Saved' : 'Error'}
+ )
+ ) : null}
+
+ );
+ if (!is_db || is_guest) {
+ return (
+
+ {innerBox}
+
+ );
+ }
+ if (!save_in_progress && !save_sucess) {
+ return (
+
+ {innerBox}
+
+ );
+ }
+ if (save_in_progress) {
+ return (
+
+ {innerBox}
+
+ );
+ }
+ return innerBox;
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx
new file mode 100644
index 0000000000000..beef2520f6782
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx
@@ -0,0 +1,81 @@
+import { Component } from 'inferno';
+import type { InfernoNode } from 'inferno';
+import { loadedMappings, resolveAsset } from '../../assets';
+import { fetchRetry } from '../../http';
+import { ServerData } from './data';
+import { Dimmer, Box } from '../../components';
+
+// Cache response so it's only sent once
+let fetchServerData: Promise | undefined;
+let lastError: any = null;
+
+export class ServerPreferencesFetcher extends Component<
+ {
+ render: (serverData: ServerData | undefined) => InfernoNode;
+ },
+ {
+ serverData?: ServerData;
+ errored: boolean;
+ }
+> {
+ constructor() {
+ super();
+ this.state = {
+ serverData: undefined,
+ errored: false,
+ };
+ }
+
+ componentDidMount() {
+ this.populateServerData();
+ }
+
+ async populateServerData() {
+ if (!fetchServerData) {
+ fetchServerData = fetchRetry(resolveAsset('preferences.json'))
+ .then((response) => response.json())
+ .catch((err) => {
+ this.setState({
+ errored: true,
+ });
+ lastError = err;
+ });
+ }
+
+ const preferencesData: ServerData = await fetchServerData;
+
+ this.setState({
+ serverData: preferencesData,
+ });
+ }
+
+ render() {
+ return this.state !== null && this.state.serverData !== null && this.state.errored === false && lastError === null ? (
+ this.props.render(this.state.serverData)
+ ) : lastError !== null ? (
+
+ Error: Unable to fetch preferences clientside data.
+
+ (Your character data is OK, this is a UI error)
+
+ Contact a maintainer or create an issue report by pressing Report Issue in the top right of the game window.
+
+
+ Error Details:{'\n'}
+ {typeof lastError === 'object' && Object.keys(lastError).includes('stack') ? lastError.stack : lastError.toString()}
+ {'\n'}
+ Asset Mappings: {JSON.stringify(loadedMappings, null, 2)}
+
+
+ ) : (
+ 'Loading...'
+ );
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx
new file mode 100644
index 0000000000000..a523663a381c9
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx
@@ -0,0 +1,362 @@
+import { classes } from 'common/react';
+import { useBackend } from '../../backend';
+import { BlockQuote, Box, Button, Divider, Icon, Section, Stack, Tooltip } from '../../components';
+import { CharacterPreview } from './CharacterPreview';
+import { createSetPreference, Food, Perk, PreferencesMenuData, ServerData, Species } from './data';
+import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
+
+const FOOD_ICONS = {
+ [Food.Cloth]: 'tshirt',
+ [Food.Dairy]: 'cheese',
+ [Food.Fried]: 'bacon',
+ [Food.Fruit]: 'apple-alt',
+ [Food.Grain]: 'bread-slice',
+ [Food.Gross]: 'trash',
+ [Food.Junkfood]: 'pizza-slice',
+ [Food.Meat]: 'hamburger',
+ [Food.Raw]: 'drumstick-bite',
+ [Food.Sugar]: 'candy-cane',
+ [Food.Toxic]: 'biohazard',
+ [Food.Vegetables]: 'carrot',
+};
+
+const FOOD_NAMES: Record = {
+ [Food.Cloth]: 'Clothing',
+ [Food.Dairy]: 'Dairy',
+ [Food.Fried]: 'Fried food',
+ [Food.Fruit]: 'Fruit',
+ [Food.Grain]: 'Grain',
+ [Food.Gross]: 'Gross food',
+ [Food.Junkfood]: 'Junk food',
+ [Food.Meat]: 'Meat',
+ [Food.Raw]: 'Raw',
+ [Food.Sugar]: 'Sugar',
+ [Food.Toxic]: 'Toxic food',
+ [Food.Vegetables]: 'Vegetables',
+};
+
+const IGNORE_UNLESS_LIKED: Set = new Set([Food.Cloth, Food.Gross, Food.Toxic]);
+
+const notIn = function (set: Set) {
+ return (value: T) => {
+ return !set.has(value);
+ };
+};
+
+const FoodList = (props: { food: Food[]; icon: string; name: string; className: string }) => {
+ if (props.food.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {props.name}
+
+
+ {props.food
+ .reduce((names, food) => {
+ const foodName = FOOD_NAMES[food];
+ return foodName ? names.concat(foodName) : names;
+ }, [])
+ .join(', ')}
+
+
+ }>
+
+ {props.food.map((food) => {
+ return (
+ FOOD_ICONS[food] && (
+
+
+
+ )
+ );
+ })}
+
+
+ );
+};
+
+const Diet = (props: { diet: Species['diet'] }) => {
+ if (!props.diet) {
+ return null;
+ }
+
+ const { liked_food, disliked_food, toxic_food } = props.diet;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const SpeciesPerk = (props: { className: string; perk: Perk }) => {
+ const { className, perk } = props;
+
+ return (
+
+ {perk.name}
+
+ {perk.description}
+
+ }>
+
+
+
+
+ );
+};
+
+const SpeciesPerks = (props: { perks: Species['perks'] }) => {
+ const { positive, negative, neutral } = props.perks;
+
+ return (
+
+
+
+ {positive.map((perk) => {
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+ {neutral.map((perk) => {
+ return (
+
+
+
+ );
+ })}
+
+
+
+ {negative.map((perk) => {
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+const SpeciesPageInner = (
+ props: {
+ handleClose: () => void;
+ species: ServerData['species'];
+ },
+ context
+) => {
+ const { act, data } = useBackend(context);
+ const setSpecies = createSetPreference(act, 'species');
+
+ let species: [string, Species][] = Object.entries(props.species).map(([species, data]) => {
+ return [species, data];
+ });
+
+ // Humans are always the top of the list
+ const humanIndex = species.findIndex(([species]) => species === 'human');
+ const swapWith = species[0];
+ species[0] = species[humanIndex];
+ species[humanIndex] = swapWith;
+
+ const currentSpecies = species.filter(([speciesKey]) => {
+ return speciesKey === data.character_preferences.misc.species;
+ })[0][1];
+
+ let selectableSpecies: [string, Species][] = species.filter(([_, s]) => s.selectable);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {selectableSpecies.map(([speciesKey, species]) => {
+ return !currentSpecies.selectable ? (
+ setSpecies(speciesKey)}
+ selected={data.character_preferences.misc.species === speciesKey}
+ tooltip={
+ <>
+ {species.name}
+
+
+ Changing species will result in the loss of ability to select {currentSpecies.name} again. Are you
+ sure?
+
+
+ >
+ }
+ content={}
+ style={{
+ display: 'block',
+ height: '64px',
+ width: '64px',
+ }}
+ />
+ ) : (
+ setSpecies(speciesKey)}
+ selected={data.character_preferences.misc.species === speciesKey}
+ tooltip={species.name}
+ content={}
+ style={{
+ display: 'block',
+ height: '64px',
+ width: '64px',
+ }}
+ />
+ );
+ })}
+ {!currentSpecies.selectable && (
+
+ {currentSpecies.name}
+
+
+ Disabled for new characters, but allowed by roundstart_no_hard_check. Changing species will result in
+ the inability to select this species again.
+
+
+ >
+ }
+ content={}
+ style={{
+ display: 'block',
+ height: '64px',
+ width: '64px',
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ }>
+
+ Unselectable, but allowed due to your existing character.
+
+ )
+ }>
+ {currentSpecies.desc}
+
+
+
+
+
+
+
+
+
+
+
+
+ {currentSpecies.lore && (
+
+
+
+ {currentSpecies.lore.map((text, index) => (
+
+ {text}
+ {index !== (currentSpecies.lore || []).length - 1 && (
+ <>
+
+
+ >
+ )}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export const SpeciesPage = (props: { closeSpecies: () => void }) => {
+ return (
+ {
+ if (serverData) {
+ return ;
+ } else {
+ return Loading species...;
+ }
+ }}
+ />
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/TabbedMenu.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/TabbedMenu.tsx
new file mode 100644
index 0000000000000..9566bb1221e4e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/TabbedMenu.tsx
@@ -0,0 +1,88 @@
+import { Component, createRef, RefObject } from 'inferno';
+import type { InfernoNode } from 'inferno';
+import { Button, Stack, Flex } from '../../components';
+import { FlexProps } from '../../components/Flex';
+import { CollapsibleSection } from 'tgui/components/CollapsibleSection';
+
+type TabbedMenuProps = {
+ categoryEntries: [string, InfernoNode][];
+ contentProps?: FlexProps;
+};
+
+export class TabbedMenu extends Component {
+ categoryRefs: Record> = {};
+ sectionRef: RefObject = createRef();
+
+ getCategoryRef(category: string): RefObject {
+ if (!this.categoryRefs[category]) {
+ this.categoryRefs[category] = createRef();
+ }
+
+ return this.categoryRefs[category];
+ }
+
+ render() {
+ return (
+
+ {this.props.children && {this.props.children}}
+ {this.props.categoryEntries?.length > 1 && (
+
+
+ {this.props.categoryEntries.map(([category]) => {
+ return (
+
+ {
+ const offsetTop = this.categoryRefs[category].current?.offsetTop;
+
+ if (offsetTop === undefined) {
+ return;
+ }
+
+ const currentSection = this.sectionRef.current;
+
+ if (!currentSection) {
+ return;
+ }
+
+ currentSection.scrollTop = offsetTop;
+ }}>
+ {category}
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+ {this.props.categoryEntries.map(([category, children]) => {
+ return (
+
+
+ {children}
+
+
+ );
+ })}
+
+
+
+ );
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts
new file mode 100644
index 0000000000000..b87cfd8c1e22a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts
@@ -0,0 +1,236 @@
+import { BooleanLike } from 'common/react';
+import { sendAct } from '../../backend';
+import { Gender } from './preferences/gender';
+
+export enum Food {
+ Alcohol = 'ALCOHOL',
+ Breakfast = 'BREAKFAST',
+ Cloth = 'CLOTH',
+ Dairy = 'DAIRY',
+ Fried = 'FRIED',
+ Fruit = 'FRUIT',
+ Grain = 'GRAIN',
+ Gross = 'GROSS',
+ Junkfood = 'JUNKFOOD',
+ Meat = 'MEAT',
+ Pineapple = 'PINEAPPLE',
+ Raw = 'RAW',
+ Sugar = 'SUGAR',
+ Toxic = 'TOXIC',
+ Vegetables = 'VEGETABLES',
+}
+
+export enum JobPriority {
+ Low = 1,
+ Medium = 2,
+ High = 3,
+}
+
+export type Name = {
+ can_randomize: BooleanLike;
+ explanation: string;
+ group: string;
+};
+
+export type Species = {
+ name: string;
+ desc: string;
+ lore?: string[];
+ icon: string;
+
+ use_skintones: BooleanLike;
+ sexes: BooleanLike;
+
+ enabled_features: string[];
+ selectable: BooleanLike;
+
+ perks: {
+ positive: Perk[];
+ negative: Perk[];
+ neutral: Perk[];
+ };
+
+ diet?: {
+ liked_food: Food[];
+ disliked_food: Food[];
+ toxic_food: Food[];
+ };
+};
+
+export type Perk = {
+ ui_icon: string;
+ name: string;
+ description: string;
+};
+
+export type Department = {
+ head?: string;
+};
+
+export type Job = {
+ description: string;
+ department: string;
+};
+
+export type Quirk = {
+ description: string;
+ icon?: string;
+ name: string;
+ value: number;
+};
+
+export type QuirkInfo = {
+ max_positive_quirks: number;
+ quirk_info: Record;
+ quirk_blacklist: string[][];
+};
+
+export type LoadoutInfo = {
+ categories: LoadoutCategory[];
+ purchased_gear: string[];
+ equipped_gear: string[];
+ metacurrency_name: string;
+};
+
+export type LoadoutGear = {
+ id: string;
+ display_name: string;
+ skirt_display_name: string | null;
+ description: string;
+ skirt_description: string | null;
+ donator: BooleanLike;
+ cost: number;
+ allowed_roles: string[] | null;
+ is_equippable: BooleanLike;
+ multi_purchase: BooleanLike;
+};
+
+export type LoadoutCategory = {
+ name: string;
+ gear: LoadoutGear[];
+};
+
+export type AntagonistData = {
+ name: string;
+ description: string;
+ category: string;
+ per_character: BooleanLike;
+ path: string;
+ icon_path: string;
+ ban_key?: string;
+};
+
+export enum RandomSetting {
+ AntagOnly = 1,
+ Disabled = 2,
+ Enabled = 3,
+}
+
+export enum JoblessRole {
+ BeOverflow = 1,
+ BeRandomJob = 2,
+ ReturnToLobby = 3,
+}
+
+export enum GamePreferencesSelectedPage {
+ Settings,
+ Keybindings,
+}
+
+export const createSetPreference = (act: typeof sendAct, preference: string) => (value: unknown) => {
+ act('set_preference', {
+ preference,
+ value,
+ });
+};
+
+export enum Window {
+ Character = 0,
+ Game = 1,
+ Keybindings = 2,
+}
+
+export type PreferencesMenuData = {
+ character_preview_view: string;
+ character_profiles: (string | null)[];
+
+ character_preferences: {
+ clothing: Record;
+ features: Record;
+ game_preferences: Record;
+ non_contextual: {
+ body_is_always_random: RandomSetting;
+ [otherKey: string]: unknown;
+ };
+ secondary_features: Record;
+ supplemental_features: Record;
+
+ names: Record;
+
+ misc: {
+ gender: Gender;
+ joblessrole: JoblessRole;
+ species: string;
+ };
+
+ randomization: Record;
+ };
+
+ content_unlocked: BooleanLike;
+
+ job_bans?: string[];
+ job_days_left?: Record;
+ job_required_experience?: Record<
+ string,
+ {
+ experience_type: string;
+ required_playtime: number;
+ }
+ >;
+ job_preferences: Record;
+
+ keybindings: Record;
+ overflow_role: string;
+ selected_quirks: string[];
+
+ purchased_gear: string[];
+ equipped_gear: string[];
+ metacurrency_balance: number;
+ is_donator: BooleanLike;
+
+ antag_bans?: string[];
+ antag_living_playtime_hours_left?: Record;
+ enabled_global: string[];
+ enabled_character: string[];
+
+ active_slot: number;
+ max_slot: number;
+ name_to_use: string;
+ save_in_progress: BooleanLike;
+ is_guest: BooleanLike;
+ is_db: BooleanLike;
+ save_sucess: BooleanLike;
+
+ window: Window;
+};
+
+export type ServerData = {
+ antags: {
+ antagonists: AntagonistData[];
+ categories: string[];
+ };
+ jobs: {
+ departments: Record;
+ jobs: Record;
+ };
+ names: {
+ types: Record;
+ };
+ quirks: QuirkInfo;
+ loadout: LoadoutInfo;
+ random: {
+ randomizable: string[];
+ };
+ species: Record;
+ [otheyKey: string]: unknown;
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/index.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/index.tsx
new file mode 100644
index 0000000000000..197a940d3a140
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/index.tsx
@@ -0,0 +1,22 @@
+import { exhaustiveCheck } from 'common/exhaustive';
+import { useBackend } from '../../backend';
+import { GamePreferencesSelectedPage, PreferencesMenuData, Window } from './data';
+import { CharacterPreferenceWindow } from './CharacterPreferenceWindow';
+import { GamePreferenceWindow } from './GamePreferenceWindow';
+
+export const PreferencesMenu = (props, context) => {
+ const { data } = useBackend(context);
+
+ const window = data.window;
+
+ switch (window) {
+ case Window.Character:
+ return ;
+ case Window.Game:
+ return ;
+ case Window.Keybindings:
+ return ;
+ default:
+ exhaustiveCheck(window);
+ }
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/names.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/names.tsx
new file mode 100644
index 0000000000000..8ba65e1afb86a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/names.tsx
@@ -0,0 +1,235 @@
+import { binaryInsertWith, sortBy } from 'common/collections';
+import { useLocalState } from '../../backend';
+import { Button, FitText, Icon, Input, LabeledList, Modal, Section, Stack, TrackOutsideClicks } from '../../components';
+import { Name } from './data';
+import { ServerPreferencesFetcher } from './ServerPreferencesFetcher';
+
+type NameWithKey = {
+ key: string;
+ name: Name;
+};
+
+const binaryInsertName = binaryInsertWith(({ key }) => key);
+
+const sortNameWithKeyEntries = sortBy<[string, NameWithKey[]]>(([key]) => key);
+
+export const MultiNameInput = (
+ props: {
+ handleClose: () => void;
+ handleRandomizeName: (nameType: string) => void;
+ handleUpdateName: (nameType: string, value: string) => void;
+ names: Record;
+ },
+ context
+) => {
+ const [currentlyEditingName, setCurrentlyEditingName] = useLocalState(context, 'currentlyEditingName', null);
+
+ return (
+ {
+ if (!data) {
+ return null;
+ }
+
+ const namesIntoGroups: Record = {};
+
+ for (const [key, name] of Object.entries(data.names.types)) {
+ namesIntoGroups[name.group] = binaryInsertName(namesIntoGroups[name.group] || [], {
+ key,
+ name,
+ });
+ }
+
+ return (
+
+
+
+
+
+ );
+ }}
+ />
+ );
+};
+
+export const NameInput = (
+ props: {
+ handleUpdateName: (name: string) => void;
+ name: string;
+ openMultiNameInput: () => void;
+ },
+ context
+) => {
+ const [lastNameBeforeEdit, setLastNameBeforeEdit] = useLocalState(context, 'lastNameBeforeEdit', null);
+ const editing = lastNameBeforeEdit === props.name;
+
+ const updateName = (e, value) => {
+ setLastNameBeforeEdit(null);
+ props.handleUpdateName(value);
+ };
+
+ return (
+ {
+ setLastNameBeforeEdit(props.name);
+ }}
+ width="100%"
+ height="28px">
+
+
+
+
+
+
+ {(editing && (
+ {
+ setLastNameBeforeEdit(null);
+ }}
+ value={props.name}
+ />
+ )) || (
+
+ {props.name}
+
+ )}
+
+
+ {/* We only know other names when the server tells us */}
+
+ data ? (
+
+ {
+ props.openMultiNameInput();
+
+ // We're a button inside a button.
+ // Did you know that's against the W3C standard? :)
+ event.cancelBubble = true;
+ event.stopPropagation();
+ }}>
+
+
+
+ ) : null
+ }
+ />
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx
new file mode 100644
index 0000000000000..21ea616c4915f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx
@@ -0,0 +1,486 @@
+import { sortBy, sortStrings } from 'common/collections';
+import { BooleanLike, classes } from 'common/react';
+import { createComponentVNode } from 'inferno';
+import type { InfernoNode, ComponentType } from 'inferno';
+import { VNodeFlags } from 'inferno-vnode-flags';
+import { sendAct, useBackend, useLocalState } from '../../../../backend';
+import { Box, Button, Dropdown, Input, NumberInput, Stack, Flex, Tooltip } from '../../../../components';
+import { createSetPreference, PreferencesMenuData } from '../../data';
+import { ServerPreferencesFetcher } from '../../ServerPreferencesFetcher';
+import features from '.';
+import { DropdownOptionalProps } from 'tgui/components/Dropdown';
+
+export const sortChoices = sortBy<[string, InfernoNode]>(([name]) => name);
+
+export type Feature = {
+ name: string;
+ component: FeatureValue;
+ category?: string;
+ subcategory?: string;
+ description?: string;
+ predictable?: boolean;
+ small_supplemental?: boolean;
+};
+
+/**
+ * Represents a preference.
+ * TReceiving = The type you will be receiving
+ * TSending = The type you will be sending
+ * TServerData = The data the server sends through preferences.json
+ */
+type FeatureValue = ComponentType<
+ FeatureValueProps
+>;
+
+export type FeatureValueProps = {
+ act: typeof sendAct;
+ featureId: string;
+ handleSetValue: (newValue: TSending) => void;
+ serverData: TServerData | undefined;
+ shrink?: boolean;
+ value?: TReceiving;
+};
+
+export const FeatureColorInput = (props: FeatureValueProps) => {
+ return (
+ {
+ props.act('set_color_preference', {
+ preference: props.featureId,
+ });
+ }}>
+
+
+
+
+
+ {!props.shrink && Change}
+
+
+ );
+};
+
+export type FeatureToggle = Feature;
+
+export const TextInput = (props: FeatureValueProps) => {
+ return props.handleSetValue(newValue)} width="100%" />;
+};
+
+export const CheckboxInput = (props: FeatureValueProps) => {
+ return (
+ {
+ props.handleSetValue(!props.value);
+ }}
+ />
+ );
+};
+
+export const CheckboxInputInverse = (props: FeatureValueProps) => {
+ return (
+ {
+ props.handleSetValue(!props.value);
+ }}
+ />
+ );
+};
+
+export const createDropdownInput = (
+ // Map of value to display texts
+ choices: Record,
+ dropdownProps?: DropdownOptionalProps
+): FeatureValue => {
+ return (props: FeatureValueProps) => {
+ return (
+ {
+ return {
+ displayText: label,
+ value: dataValue,
+ };
+ })}
+ {...dropdownProps}
+ />
+ );
+ };
+};
+
+export type FeatureChoicedServerData = {
+ choices: string[];
+ display_names?: Record;
+ icons?: Record;
+ icon_sheet?: string;
+};
+
+export type FeatureChoiced = Feature;
+
+const capitalizeFirstLetter = (text: string) =>
+ text
+ .toString()
+ .charAt(0)
+ .toUpperCase() + text.toString().slice(1);
+
+export const StandardizedDropdown = (props: {
+ choices: string[];
+ disabled?: boolean;
+ displayNames: Record;
+ onSetValue: (newValue: string) => void;
+ value?: string;
+ buttons?: boolean;
+ displayHeight?: string;
+}) => {
+ const { choices, disabled, buttons, displayNames, onSetValue, displayHeight, value } = props;
+
+ return (
+ {
+ return {
+ displayText: displayNames[choice],
+ value: choice,
+ };
+ })}
+ />
+ );
+};
+
+export const FeatureButtonedDropdownInput = (
+ props: FeatureValueProps & {
+ disabled?: boolean;
+ }
+) => {
+ return ;
+};
+
+export const FeatureDropdownInput = (
+ props: FeatureValueProps & {
+ disabled?: boolean;
+ buttons?: boolean;
+ }
+) => {
+ const serverData = props.serverData;
+ if (!serverData) {
+ return null;
+ }
+
+ const displayNames =
+ serverData.display_names || Object.fromEntries(serverData.choices.map((choice) => [choice, capitalizeFirstLetter(choice)]));
+
+ return serverData.choices.length > 5 ? (
+
+ ) : (
+
+ );
+};
+
+export const FeatureIconnedDropdownInput = (
+ props: FeatureValueProps & {
+ buttons?: boolean;
+ }
+) => {
+ const serverData = props.serverData;
+ if (!serverData) {
+ return null;
+ }
+
+ const icons = serverData.icons;
+
+ const textNames =
+ serverData.display_names || Object.fromEntries(serverData.choices.map((choice) => [choice, capitalizeFirstLetter(choice)]));
+
+ const displayNames = Object.fromEntries(
+ Object.entries(textNames).map(([choice, textName]) => {
+ let element: InfernoNode = textName;
+
+ if (icons && icons[choice]) {
+ const icon = icons[choice];
+ element = (
+
+
+
+
+
+
+ {element}
+
+
+ );
+ }
+
+ return [choice, element];
+ })
+ );
+
+ return (
+
+ );
+};
+
+export const StandardizedChoiceButtons = (props: {
+ choices: string[];
+ disabled?: boolean;
+ displayNames: Record;
+ onSetValue: (newValue: string) => void;
+ value?: string;
+}) => {
+ const { choices, disabled, displayNames, onSetValue, value } = props;
+ return (
+ <>
+ {choices.map((choice) => (
+ onSetValue(choice)}
+ />
+ ))}
+ >
+ );
+};
+
+export type HexValue = {
+ lightness: number;
+ value: string;
+};
+
+export const StandardizedPalette = (props: {
+ choices: string[];
+ choices_to_hex?: Record;
+ disabled?: boolean;
+ displayNames: Record;
+ onSetValue: (newValue: string) => void;
+ value?: string;
+ hex_values?: boolean;
+ allow_custom?: boolean;
+ act?: typeof sendAct;
+ featureId?: string;
+ maxWidth?: string;
+ backgroundColor?: string;
+ includeHex?: boolean;
+ height?: number;
+}) => {
+ const {
+ choices,
+ disabled,
+ displayNames,
+ onSetValue,
+ hex_values,
+ allow_custom,
+ maxWidth = '100%',
+ backgroundColor,
+ includeHex = false,
+ height = 16,
+ } = props;
+ const choices_to_hex = hex_values ? Object.fromEntries(choices.map((v) => [v, v])) : props.choices_to_hex!;
+ const safeHex = (v: string) => {
+ if (v.length === 3) {
+ // sanitize short colors
+ v = v[0] + v[0] + v[1] + v[1] + v[2] + v[2];
+ } else if (v.length === 4) {
+ v = v[1] + v[1] + v[2] + v[2] + v[3] + v[3];
+ }
+ return (v.startsWith('#') ? v : `#${v}`).toLowerCase();
+ };
+ const safeValue = hex_values ? props.value && safeHex(props.value) : props.value;
+ return (
+
+
+
+ {choices.map((choice) => (
+
+
+ onSetValue(hex_values ? safeHex(choice) : choice)}
+ width={height + 'px'}
+ height={height + 'px'}>
+
+
+
+
+ ))}
+ {allow_custom && (
+ <>
+
+ {!Object.values(choices_to_hex)
+ .map(safeHex)
+ .includes(safeValue!) && (
+
+
+
+
+
+
+
+ )}
+
+
+ {
+ if (props.act && props.featureId) {
+ props.act('set_color_preference', {
+ preference: props.featureId,
+ });
+ }
+ }}
+ />
+
+ >
+ )}
+
+
+
+ );
+};
+
+export type FeatureNumericData = {
+ minimum: number;
+ maximum: number;
+ step: number;
+};
+
+export type FeatureNumeric = Feature;
+
+export const FeatureNumberInput = (props: FeatureValueProps) => {
+ if (!props.serverData) {
+ return Loading...;
+ }
+
+ return (
+ {
+ props.handleSetValue(value);
+ }}
+ minValue={props.serverData.minimum}
+ maxValue={props.serverData.maximum}
+ step={props.serverData.step}
+ value={props.value}
+ />
+ );
+};
+
+export const FeatureValueInput = (
+ props: {
+ feature: Feature;
+ featureId: string;
+ shrink?: boolean;
+ value: unknown;
+
+ act: typeof sendAct;
+ },
+ context
+) => {
+ const { data } = useBackend(context);
+
+ const feature = props.feature;
+
+ const [predictedValue, setPredictedValue] =
+ feature.predictable === undefined || feature.predictable
+ ? useLocalState(context, `${props.featureId}_predictedValue_${data.active_slot}`, props.value)
+ : [props.value, () => {}];
+
+ const changeValue = (newValue: unknown) => {
+ setPredictedValue(newValue);
+ createSetPreference(props.act, props.featureId)(newValue);
+ };
+
+ return (
+ {
+ return createComponentVNode(VNodeFlags.ComponentUnknown, feature.component, {
+ act: props.act,
+ featureId: props.featureId,
+ serverData: serverData && serverData[props.featureId],
+ shrink: props.shrink,
+
+ handleSetValue: changeValue,
+ value: predictedValue,
+ });
+ }}
+ />
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/age.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/age.tsx
new file mode 100644
index 0000000000000..3b0dc68158f28
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/age.tsx
@@ -0,0 +1,6 @@
+import { Feature, FeatureNumberInput } from '../base';
+
+export const age: Feature = {
+ name: 'Age',
+ component: FeatureNumberInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/ai_core_display.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/ai_core_display.tsx
new file mode 100644
index 0000000000000..8c52e0d0cd3aa
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/ai_core_display.tsx
@@ -0,0 +1,8 @@
+import { FeatureIconnedDropdownInput, FeatureValueProps, FeatureChoicedServerData, FeatureChoiced } from '../base';
+
+export const preferred_ai_core_display: FeatureChoiced = {
+ name: 'AI Core Display',
+ component: (props: FeatureValueProps) => {
+ return ;
+ },
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/body_type.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/body_type.tsx
new file mode 100644
index 0000000000000..ec1ae323c769b
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/body_type.tsx
@@ -0,0 +1,11 @@
+import { FeatureChoiced, FeatureButtonedDropdownInput } from '../base';
+
+export const body_model: FeatureChoiced = {
+ name: 'Body Type',
+ component: FeatureButtonedDropdownInput,
+};
+
+export const body_size: FeatureChoiced = {
+ name: 'Body Size',
+ component: FeatureButtonedDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/pda.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/pda.tsx
new file mode 100644
index 0000000000000..643b62b79729a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/pda.tsx
@@ -0,0 +1,11 @@
+import { Feature, FeatureChoiced, FeatureChoicedServerData, FeatureColorInput, FeatureButtonedDropdownInput, FeatureValueProps } from '../base';
+
+export const pda_theme: FeatureChoiced = {
+ name: 'PDA Theme',
+ component: FeatureButtonedDropdownInput,
+};
+
+export const pda_classic_color: Feature = {
+ name: 'Thinktronic Classic Color',
+ component: FeatureColorInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/security_department.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/security_department.tsx
new file mode 100644
index 0000000000000..bd808d98a0e15
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/security_department.tsx
@@ -0,0 +1,6 @@
+import { FeatureChoiced, FeatureButtonedDropdownInput } from '../base';
+
+export const preferred_security_department: FeatureChoiced = {
+ name: 'Preferred Security Department',
+ component: FeatureButtonedDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skin_tone.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skin_tone.tsx
new file mode 100644
index 0000000000000..3c1c2eb0034ff
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skin_tone.tsx
@@ -0,0 +1,30 @@
+import { sortBy } from 'common/collections';
+import { Feature, FeatureChoicedServerData, FeatureValueProps, HexValue, StandardizedPalette } from '../base';
+
+type SkinToneServerData = FeatureChoicedServerData & {
+ display_names: NonNullable;
+ to_hex: Record;
+};
+
+const sortHexValues = sortBy<[string, HexValue]>(([_, hexValue]) => -hexValue.lightness);
+
+export const skin_tone: Feature = {
+ name: 'Skin Tone',
+ component: (props: FeatureValueProps) => {
+ const { handleSetValue, serverData, value } = props;
+
+ if (!serverData) {
+ return null;
+ }
+
+ return (
+ key)}
+ choices_to_hex={Object.fromEntries(Object.entries(serverData.to_hex).map(([key, hex]) => [key, hex.value]))}
+ displayNames={serverData.display_names}
+ onSetValue={handleSetValue}
+ value={value}
+ />
+ );
+ },
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/species_features.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/species_features.tsx
new file mode 100644
index 0000000000000..5d9ab86393e12
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/species_features.tsx
@@ -0,0 +1,216 @@
+import { FeatureColorInput, Feature, FeatureChoiced, FeatureValueProps, FeatureButtonedDropdownInput, StandardizedPalette } from '../base';
+
+const eyePresets = {
+ // these need to be short color (3 byte) compatible
+ '#aaccff': 'Baby Blue',
+ '#0099bb': 'Blue-Green',
+ '#3399ff': 'Light Blue',
+ '#0066ff': 'Blue',
+ '#337788': 'Teal Blue',
+ '#005577': 'Dark Cerulean Blue',
+ '#889988': 'Artichoke Green',
+ '#447766': 'Green-Blue',
+ '#117744': 'Teal Green',
+ '#336611': 'Forest Green',
+ '#aaaa66': 'Hazel',
+ '#554411': 'Brown-Green',
+ '#664433': 'Brown',
+ '#663300': 'Rich Brown',
+ '#441100': 'Deep Brown',
+ '#884400': 'Amber',
+ '#667788': 'Gray',
+ '#445566': 'Deep Gray',
+ '#eeeeee': 'Albino White',
+ '#ccaaaa': 'Albino Pink',
+ '#bbddee': 'Albino Blue',
+};
+
+export const eye_color: Feature = {
+ name: 'Eye Color',
+ small_supplemental: false,
+ predictable: false,
+ component: (props: FeatureValueProps) => {
+ const { handleSetValue, value, featureId, act } = props;
+
+ return (
+
+ );
+ },
+};
+
+const hairPresets = {
+ // these need to be short color (3 byte) compatible
+ '#111111': 'Black',
+ '#222222': 'Off Black',
+ '#332222': 'Deep Brown',
+ '#443322': 'Dark Brown',
+ '#443333': 'Medium Dark Brown',
+ '#553333': 'Dark Chestnut Brown',
+ '#664444': 'Light Chestnut Brown',
+ '#554433': 'Dark Golden Brown',
+ '#997766': 'Light Ash Brown',
+ '#aa8866': 'Light Golden Brown',
+ '#bb9977': 'Dark Honey Blonde',
+ '#ddbb99': 'Light Ash Blonde',
+ '#eeccaa': 'Light Blonde',
+ '#eeddbb': 'Bleached Blonde',
+ '#666666': 'Dark Gray',
+ '#999999': 'Medium Gray',
+ '#bbbbbb': 'Light Gray',
+ '#ffeeee': 'White',
+ '#884444': 'Soft Auburn',
+ '#bb5544': 'Soft Terracotta',
+ '#aa5500': 'Ginger Brown',
+ '#cc5522': 'Ginger Orange',
+ '#ff0000': 'Vibrant Red',
+ '#aa0000': 'Simply Red',
+ '#ff7700': 'Vibrant Orange',
+ '#ffff00': 'Vibrant Yellow',
+ '#aa9900': 'Simply Yellow',
+ '#00ff00': 'Vibrant Green',
+ '#00aa00': 'Simply Green',
+ '#00ccaa': 'Turqouise',
+ '#00ffff': 'Vibrant Cyan',
+ '#00aaaa': 'Simply Cyan',
+ '#229988': 'Teal',
+ '#0000ff': 'Vibrant Blue',
+ '#0000aa': 'Simply Blue',
+ '#6600ff': 'Vibrant Indigo',
+ '#9922ff': 'Purple',
+ '#8800ff': 'Violet',
+ '#550088': 'Deep Purple',
+ '#ff00ff': 'Vibrant Magenta',
+ '#aa00aa': 'Simply Magenta',
+ '#ff0066': 'Raspberry',
+ '#ff2288': 'Hot Pink',
+ '#ff99bb': 'Pink',
+ '#ee8888': 'Faded Pink',
+};
+
+export const facial_hair_color: Feature = {
+ name: 'Facial Hair Color',
+ small_supplemental: false,
+ predictable: false,
+ component: (props: FeatureValueProps) => {
+ const { handleSetValue, value, featureId, act } = props;
+
+ return (
+
+ );
+ },
+};
+
+export const hair_color: Feature = {
+ name: 'Hair Color',
+ small_supplemental: false,
+ predictable: false,
+ component: (props: FeatureValueProps) => {
+ const { handleSetValue, value, featureId, act } = props;
+
+ return (
+
+ );
+ },
+};
+
+export const gradient_color: Feature = {
+ name: 'Gradient Color',
+ small_supplemental: false,
+ predictable: false,
+ component: (props: FeatureValueProps) => {
+ const { handleSetValue, value, featureId, act } = props;
+
+ return (
+
+ );
+ },
+};
+
+export const feature_lizard_legs: FeatureChoiced = {
+ name: 'Leg Type',
+ component: FeatureButtonedDropdownInput,
+};
+
+export const feature_mcolor: Feature = {
+ name: 'Mutant Color',
+ component: FeatureColorInput,
+};
+
+export const underwear_color: Feature = {
+ name: 'Underwear Color',
+ component: FeatureColorInput,
+};
+
+export const helmet_style: FeatureChoiced = {
+ name: 'Helmet Style',
+ component: FeatureButtonedDropdownInput,
+};
+
+export const feature_ipc_antenna_color: Feature = {
+ name: 'Antenna Color',
+ component: FeatureColorInput,
+};
+
+export const feature_ipc_screen_color: Feature = {
+ name: 'Screen Color',
+ component: FeatureColorInput,
+};
+
+export const feature_human_tail: FeatureChoiced = {
+ name: 'Tail',
+ component: FeatureButtonedDropdownInput,
+};
+
+export const feature_human_ears: FeatureChoiced = {
+ name: 'Ears',
+ component: FeatureButtonedDropdownInput,
+};
+
+export const feature_insect_type: FeatureChoiced = {
+ name: 'Insect Type',
+ component: FeatureButtonedDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/uplink_loc.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/uplink_loc.tsx
new file mode 100644
index 0000000000000..2f2381d285665
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/uplink_loc.tsx
@@ -0,0 +1,6 @@
+import { FeatureChoiced, FeatureButtonedDropdownInput } from '../base';
+
+export const uplink_loc: FeatureChoiced = {
+ name: 'Uplink Spawn Location',
+ component: FeatureButtonedDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/admin.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/admin.tsx
new file mode 100644
index 0000000000000..67211ce275200
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/admin.tsx
@@ -0,0 +1,25 @@
+import { FeatureColorInput, Feature, FeatureToggle, CheckboxInput } from '../base';
+
+export const asaycolor: Feature = {
+ name: 'Admin chat color',
+ category: 'ADMIN',
+ subcategory: 'Chat',
+ description: 'The color of your messages in Adminsay.',
+ component: FeatureColorInput,
+};
+
+export const announce_login: FeatureToggle = {
+ name: 'Announce Login',
+ category: 'ADMIN',
+ subcategory: 'Misc',
+ description: 'Whether you will announce whenever you login to fellow admins or not.',
+ component: CheckboxInput,
+};
+
+export const combohud_lighting: FeatureToggle = {
+ name: 'Combo HUD Lighting',
+ category: 'ADMIN',
+ subcategory: 'Misc',
+ description: 'Whether you see combo HUD lighting as fullbright or not.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ambient_occlusion.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ambient_occlusion.tsx
new file mode 100644
index 0000000000000..12d677dbcc619
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ambient_occlusion.tsx
@@ -0,0 +1,9 @@
+import { CheckboxInput, FeatureToggle } from '../base';
+
+export const ambientocclusion: FeatureToggle = {
+ name: 'Enable ambient occlusion',
+ category: 'GRAPHICS',
+ subcategory: 'Quality',
+ description: 'Adds soft shadows around the edges of objects.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/auto_fit_viewport.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/auto_fit_viewport.tsx
new file mode 100644
index 0000000000000..b7099190eb1df
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/auto_fit_viewport.tsx
@@ -0,0 +1,10 @@
+import { CheckboxInput, FeatureToggle } from '../base';
+
+export const auto_fit_viewport: FeatureToggle = {
+ name: 'Auto fit viewport',
+ category: 'GRAPHICS',
+ subcategory: 'Scaling',
+ description:
+ 'Automatically resize the map panel to chat panel ratio to fit the map size, removing black bars from the edges of the view.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/buttons_locked.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/buttons_locked.tsx
new file mode 100644
index 0000000000000..62bd39ec412ad
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/buttons_locked.tsx
@@ -0,0 +1,9 @@
+import { CheckboxInput, FeatureToggle } from '../base';
+
+export const buttons_locked: FeatureToggle = {
+ name: 'Lock action buttons',
+ category: 'UI',
+ subcategory: 'HUD',
+ description: 'When enabled, action buttons will be locked in place.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/chat.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/chat.tsx
new file mode 100644
index 0000000000000..26470e25600b1
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/chat.tsx
@@ -0,0 +1,113 @@
+import { multiline } from 'common/string';
+import { FeatureToggle, CheckboxInput } from '../base';
+
+export const chat_bankcard: FeatureToggle = {
+ name: 'Enable Income Updates',
+ category: 'CHAT',
+ subcategory: 'IC',
+ description: 'Receive notifications for your bank account.',
+ component: CheckboxInput,
+};
+
+export const chat_followghostmindless: FeatureToggle = {
+ name: '(F) Mindless',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description:
+ 'When enabled, (F) will be prefixed on mindless mobs in deadchat. When disabled, it will only be shown on mobs with minds.',
+ component: CheckboxInput,
+};
+
+export const chat_ghostears: FeatureToggle = {
+ name: 'Speech',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description: multiline`
+ When enabled, you will be able to hear all speech as a ghost.
+ When disabled, you will only be able to hear nearby speech.
+ `,
+ component: CheckboxInput,
+};
+
+export const chat_ghostlaws: FeatureToggle = {
+ name: 'Law Changes',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description: 'When enabled, be notified of any new law changes as a ghost.',
+ component: CheckboxInput,
+};
+
+export const chat_ghostpda: FeatureToggle = {
+ name: 'PDA Messages',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description: 'When enabled, be notified of any PDA messages as a ghost.',
+ component: CheckboxInput,
+};
+
+export const chat_ghostradio: FeatureToggle = {
+ name: 'Radio',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description: 'When enabled, be notified of any radio messages as a ghost.',
+ component: CheckboxInput,
+};
+
+export const chat_ghostsight: FeatureToggle = {
+ name: 'Emotes',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description: 'When enabled, see all emotes as a ghost.',
+ component: CheckboxInput,
+};
+
+export const chat_ghostwhisper: FeatureToggle = {
+ name: 'Whispers',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description: multiline`
+ When enabled, you will be able to hear all whispers as a ghost.
+ When disabled, you will only be able to hear nearby whispers.
+ `,
+ component: CheckboxInput,
+};
+
+export const chat_ooc: FeatureToggle = {
+ name: 'Enable OOC',
+ category: 'CHAT',
+ subcategory: 'OOC',
+ component: CheckboxInput,
+};
+
+export const chat_pullr: FeatureToggle = {
+ name: 'Enable Pull Request Notifications',
+ category: 'CHAT',
+ subcategory: 'OOC',
+ description: 'Be notified when a pull request is made, closed, or merged.',
+ component: CheckboxInput,
+};
+
+// Admin
+
+export const chat_dead: FeatureToggle = {
+ name: 'Hear Deadchat',
+ category: 'ADMIN',
+ subcategory: 'Chat',
+ description: 'Hear all deadchat while adminned.',
+ component: CheckboxInput,
+};
+
+export const chat_prayer: FeatureToggle = {
+ name: 'Hear Prayers',
+ category: 'ADMIN',
+ subcategory: 'Chat',
+ component: CheckboxInput,
+};
+
+export const chat_radio: FeatureToggle = {
+ name: 'Hear Radio',
+ category: 'ADMIN',
+ subcategory: 'Chat',
+ description: 'Hear all radio messages while adminned.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/crew_objectives.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/crew_objectives.tsx
new file mode 100644
index 0000000000000..e6ed6e9b4e851
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/crew_objectives.tsx
@@ -0,0 +1,8 @@
+import { FeatureToggle, CheckboxInput } from '../base';
+
+export const crew_objectives: FeatureToggle = {
+ name: 'Crew Objectives',
+ category: 'GAMEPLAY',
+ description: 'Whether you will be given crew objectives at roundstart.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/deadmin.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/deadmin.tsx
new file mode 100644
index 0000000000000..152e19d26b36e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/deadmin.tsx
@@ -0,0 +1,68 @@
+import { BooleanLike } from 'common/react';
+import { Button } from '../../../../../components';
+import { Feature, FeatureValueProps } from '../base';
+
+type FeatureToggleDeadminServerData = {
+ forced: BooleanLike;
+};
+
+type FeatureToggleDeadmin = Feature;
+
+const DeadminCheckboxInput = (props: FeatureValueProps) => {
+ const forced = props.serverData?.forced;
+ return (
+ {
+ if (!forced) {
+ props.handleSetValue(!props.value);
+ }
+ }}
+ />
+ );
+};
+
+export const deadmin_always: FeatureToggleDeadmin = {
+ name: 'Always Deadmin',
+ category: 'ADMIN',
+ subcategory: 'Deadmin',
+ description: 'Whether you will always deadmin when joining a round.',
+ component: DeadminCheckboxInput,
+};
+
+export const deadmin_antagonist: FeatureToggleDeadmin = {
+ name: 'Deadmin As Antagonist',
+ category: 'ADMIN',
+ subcategory: 'Deadmin',
+ description: 'Whether you will always deadmin when joining a round as an antagonist.',
+ component: DeadminCheckboxInput,
+};
+
+export const deadmin_position_head: FeatureToggleDeadmin = {
+ name: 'Deadmin As Head of Staff',
+ category: 'ADMIN',
+ subcategory: 'Deadmin',
+ description: 'Whether you will always deadmin when joining a round as a head of staff.',
+ component: DeadminCheckboxInput,
+};
+
+export const deadmin_position_security: FeatureToggleDeadmin = {
+ name: 'Deadmin As Security',
+ category: 'ADMIN',
+ subcategory: 'Deadmin',
+ description: 'Whether you will always deadmin when joining a round as security.',
+ component: DeadminCheckboxInput,
+};
+
+export const deadmin_position_silicon: FeatureToggleDeadmin = {
+ name: 'Deadmin As Silicon',
+ category: 'ADMIN',
+ subcategory: 'Deadmin',
+ description: 'Whether you will always deadmin when joining a round as a silicon.',
+ component: DeadminCheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fps.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fps.tsx
new file mode 100644
index 0000000000000..096b7776ce174
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fps.tsx
@@ -0,0 +1,57 @@
+import { Dropdown, NumberInput, Stack } from '../../../../../components';
+import { Feature, FeatureNumericData, FeatureValueProps } from '../base';
+
+type FpsServerData = FeatureNumericData & {
+ recommended_fps: number;
+};
+
+const FpsInput = (props: FeatureValueProps) => {
+ const { handleSetValue, serverData } = props;
+
+ let recommened = `Recommended`;
+ if (serverData) {
+ recommened += ` (${serverData.recommended_fps})`;
+ }
+
+ return (
+
+
+ {
+ if (value === recommened) {
+ handleSetValue(-1);
+ } else {
+ handleSetValue(serverData?.recommended_fps || 40);
+ }
+ }}
+ width="100%"
+ buttons
+ options={[recommened, 'Custom']}
+ />
+
+
+
+ {serverData && props.value !== -1 && (
+ {
+ props.handleSetValue(value);
+ }}
+ minValue={1}
+ maxValue={serverData.maximum}
+ value={props.value}
+ />
+ )}
+
+
+ );
+};
+
+export const clientfps: Feature = {
+ name: 'FPS',
+ category: 'GRAPHICS',
+ subcategory: 'Quality',
+ component: FpsInput,
+ predictable: false,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost.tsx
new file mode 100644
index 0000000000000..e9b10670a1511
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost.tsx
@@ -0,0 +1,127 @@
+import { multiline } from 'common/string';
+import { CheckboxInput, FeatureChoiced, FeatureChoicedServerData, FeatureDropdownInput, FeatureButtonedDropdownInput, FeatureToggle, FeatureValueProps } from '../base';
+import { Box, Dropdown, Flex } from '../../../../../components';
+import { classes } from 'common/react';
+import type { InfernoNode } from 'inferno';
+import { binaryInsertWith } from 'common/collections';
+import { useBackend } from '../../../../../backend';
+import { PreferencesMenuData } from '../../../data';
+
+export const ghost_accs: FeatureChoiced = {
+ name: 'Ghost accessories',
+ category: 'GHOST',
+ subcategory: 'Appearance',
+ description: 'Determines what adjustments your ghost will have.',
+ component: FeatureButtonedDropdownInput,
+};
+
+const insertGhostForm = binaryInsertWith<{
+ displayText: InfernoNode;
+ value: string;
+}>(({ value }) => value);
+
+const GhostFormInput = (props: FeatureValueProps, context) => {
+ const { data } = useBackend(context);
+
+ const serverData = props.serverData;
+ if (!serverData) {
+ return;
+ }
+
+ const displayNames = serverData.display_names;
+ if (!displayNames) {
+ return No display names for ghost_form!;
+ }
+
+ const displayTexts = {};
+ let options: {
+ displayText: InfernoNode;
+ value: string;
+ }[] = [];
+
+ for (const [name, displayName] of Object.entries(displayNames)) {
+ const displayText = (
+
+
+
+
+
+ {displayName}
+
+ );
+
+ displayTexts[name] = displayText;
+
+ const optionEntry = {
+ displayText,
+ value: name,
+ };
+
+ // Put the default ghost on top
+ if (name === 'ghost') {
+ options.unshift(optionEntry);
+ } else {
+ options = insertGhostForm(options, optionEntry);
+ }
+ }
+
+ return (
+
+ );
+};
+
+export const ghost_form: FeatureChoiced = {
+ name: 'Ghost Appearance',
+ category: 'BYOND MEMBER',
+ description: 'The appearance of your ghost. Requires BYOND membership.',
+ component: GhostFormInput,
+};
+
+export const ghost_hud: FeatureToggle = {
+ name: 'Ghost HUD',
+ category: 'UI',
+ subcategory: 'HUD',
+ description: 'Enable HUD buttons for ghosts.',
+ component: CheckboxInput,
+};
+
+export const ghost_orbit: FeatureChoiced = {
+ name: 'Ghost Orbit Shape',
+ category: 'BYOND MEMBER',
+ description: multiline`
+ The shape in which your ghost will orbit.
+ Requires BYOND membership.
+ `,
+ component: (props: FeatureValueProps, context) => {
+ const { data } = useBackend(context);
+
+ return ;
+ },
+};
+
+export const ghost_others: FeatureChoiced = {
+ name: 'Ghosts of others',
+ category: 'GHOST',
+ subcategory: 'Appearance',
+ description: multiline`
+ Do you want the ghosts of others to show up as their own setting, as
+ their default sprites, or always as the default white ghost?
+ `,
+ component: FeatureButtonedDropdownInput,
+};
+
+export const inquisitive_ghost: FeatureToggle = {
+ name: 'Ghost inquisitiveness',
+ category: 'GHOST',
+ subcategory: 'Behavior',
+ description: 'Clicking on something as a ghost will examine it.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/glasses.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/glasses.tsx
new file mode 100644
index 0000000000000..8c6050eb3a2c6
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/glasses.tsx
@@ -0,0 +1,9 @@
+import { FeatureToggle, CheckboxInput } from '../base';
+
+export const glasses_color: FeatureToggle = {
+ name: 'Enable glasses tint',
+ category: 'GRAPHICS',
+ subcategory: 'Misc',
+ description: "Glasses will tint your entire screen's color to match their color.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hotkeys.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hotkeys.tsx
new file mode 100644
index 0000000000000..04ed6c04558f7
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/hotkeys.tsx
@@ -0,0 +1,8 @@
+import { CheckboxInputInverse, FeatureToggle } from '../base';
+
+export const hotkeys: FeatureToggle = {
+ name: 'Classic hotkeys',
+ category: 'GAMEPLAY',
+ description: 'When enabled, will revert to the legacy hotkeys, using the input bar rather than popups.',
+ component: CheckboxInputInverse,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/item_outlines.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/item_outlines.tsx
new file mode 100644
index 0000000000000..b17ab3e94fdec
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/item_outlines.tsx
@@ -0,0 +1,17 @@
+import { CheckboxInput, FeatureToggle, FeatureColorInput, Feature } from '../base';
+
+export const itemoutline_pref: FeatureToggle = {
+ name: 'Item outlines',
+ category: 'UI',
+ subcategory: 'HUD',
+ description: 'When enabled, hovering over items will outline them.',
+ component: CheckboxInput,
+};
+
+export const outline_color: Feature = {
+ name: 'Item outline color',
+ category: 'UI',
+ subcategory: 'HUD',
+ description: 'The color of that hovered items will outline with.',
+ component: FeatureColorInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ooc.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ooc.tsx
new file mode 100644
index 0000000000000..ed8eeeebc7fb7
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ooc.tsx
@@ -0,0 +1,16 @@
+import { CheckboxInput, FeatureColorInput, FeatureToggle, Feature } from '../base';
+
+export const ooccolor: Feature = {
+ name: 'OOC Color',
+ category: 'CHAT',
+ subcategory: 'OOC',
+ description: 'The color of your OOC messages.',
+ component: FeatureColorInput,
+};
+
+export const member_public: FeatureToggle = {
+ name: 'Show BYOND Membership',
+ category: 'BYOND MEMBER',
+ description: 'Whether to show your BYOND membership in OOC or not.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/parallax.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/parallax.tsx
new file mode 100644
index 0000000000000..8e88b1699f693
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/parallax.tsx
@@ -0,0 +1,8 @@
+import { Feature, FeatureButtonedDropdownInput } from '../base';
+
+export const parallax: Feature = {
+ name: 'Space Parallax',
+ category: 'GRAPHICS',
+ subcategory: 'Quality',
+ component: FeatureButtonedDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pixel_size.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pixel_size.tsx
new file mode 100644
index 0000000000000..0bd4c0e8c92b2
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pixel_size.tsx
@@ -0,0 +1,21 @@
+import { createDropdownInput, Feature } from '../base';
+
+export const pixel_size: Feature = {
+ name: 'Pixel Scaling',
+ category: 'GRAPHICS',
+ subcategory: 'Scaling',
+ description:
+ 'The size of the game and its icons within the map window. Stretch to fit works with all screen sizes, but Pixel Perfect will produce cleaner scaling, if any fit your window.',
+ component: createDropdownInput(
+ {
+ 0: 'Stretch to fit',
+ 1: 'Pixel Perfect 1x',
+ 1.5: 'Pixel Perfect 1.5x',
+ 2: 'Pixel Perfect 2x',
+ 3: 'Pixel Perfect 3x',
+ },
+ {
+ buttons: true,
+ }
+ ),
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/preferred_map.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/preferred_map.tsx
new file mode 100644
index 0000000000000..655e28ee6d13a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/preferred_map.tsx
@@ -0,0 +1,13 @@
+import { multiline } from 'common/string';
+import { FeatureChoiced, FeatureButtonedDropdownInput } from '../base';
+
+export const preferred_map: FeatureChoiced = {
+ name: 'Preferred map',
+ category: 'GAMEPLAY',
+ description: multiline`
+ During map rotation, prefer this map be chosen.
+ This does not affect the map vote, only random rotation when a vote
+ is not held.
+ `,
+ component: FeatureButtonedDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/rattle.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/rattle.tsx
new file mode 100644
index 0000000000000..2212c5ba3f8ba
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/rattle.tsx
@@ -0,0 +1,17 @@
+import { CheckboxInput, FeatureToggle } from '../base';
+
+export const arrivals_rattle: FeatureToggle = {
+ name: 'Arrivals',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description: 'When enabled, you will be notified as a ghost for new crew.',
+ component: CheckboxInput,
+};
+
+export const death_rattle: FeatureToggle = {
+ name: 'Deaths',
+ category: 'GHOST',
+ subcategory: 'Chat',
+ description: 'When enabled, you will be notified as a ghost whenever someone dies.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/roundend.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/roundend.tsx
new file mode 100644
index 0000000000000..d582498976e9f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/roundend.tsx
@@ -0,0 +1,9 @@
+import { FeatureToggle, CheckboxInput } from '../base';
+
+export const show_credits: FeatureToggle = {
+ name: 'Show Credits',
+ category: 'UI',
+ subcategory: 'HUD',
+ description: 'Enables scrolling credit screen at roundend.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/runechat.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/runechat.tsx
new file mode 100644
index 0000000000000..da2e6a8f0f877
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/runechat.tsx
@@ -0,0 +1,41 @@
+import { Feature, CheckboxInput, FeatureButtonedDropdownInput, FeatureNumberInput, FeatureNumeric, FeatureToggle } from '../base';
+
+export const chat_on_map: FeatureToggle = {
+ name: 'Enable Runechat',
+ category: 'CHAT',
+ subcategory: 'Runechat',
+ description: 'Chat messages will show above heads.',
+ component: CheckboxInput,
+};
+
+export const see_chat_non_mob: FeatureToggle = {
+ name: 'Enable Runechat on objects',
+ category: 'CHAT',
+ subcategory: 'Runechat',
+ description: 'Chat messages will show above objects when they speak.',
+ component: CheckboxInput,
+};
+
+export const see_rc_emotes: FeatureToggle = {
+ name: 'Enable Runechat emotes',
+ category: 'CHAT',
+ subcategory: 'Runechat',
+ description: 'Emotes will show above heads.',
+ component: CheckboxInput,
+};
+
+export const max_chat_length: FeatureNumeric = {
+ name: 'Max runechat length',
+ category: 'CHAT',
+ subcategory: 'Runechat',
+ description: 'The maximum length a Runechat message will show as.',
+ component: FeatureNumberInput,
+};
+
+export const show_balloon_alerts: Feature = {
+ name: 'Show balloon alerts',
+ category: 'CHAT',
+ subcategory: 'Runechat',
+ description: 'Show text above items when certain interactions are used.',
+ component: FeatureButtonedDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/scaling_method.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/scaling_method.tsx
new file mode 100644
index 0000000000000..e6066699ba22d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/scaling_method.tsx
@@ -0,0 +1,19 @@
+import { createDropdownInput, Feature } from '../base';
+
+export const scaling_method: Feature = {
+ name: 'Scaling method',
+ category: 'GRAPHICS',
+ subcategory: 'Scaling',
+ description:
+ 'The scaling algorithm used by BYOND to resize game objects. Point sampling looks best, followed by Nearest Neighbor.',
+ component: createDropdownInput(
+ {
+ blur: 'Bilinear',
+ distort: 'Nearest Neighbor',
+ normal: 'Point Sampling',
+ },
+ {
+ buttons: true,
+ }
+ ),
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/sound.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/sound.tsx
new file mode 100644
index 0000000000000..33e8b460f02b2
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/sound.tsx
@@ -0,0 +1,79 @@
+import { FeatureToggle, CheckboxInput } from '../base';
+
+export const sound_adminhelp: FeatureToggle = {
+ name: 'Enable adminhelp sounds',
+ category: 'ADMIN',
+ subcategory: 'Sound',
+ component: CheckboxInput,
+};
+
+export const sound_ambience: FeatureToggle = {
+ name: 'Enable ambience',
+ category: 'SOUND',
+ subcategory: 'Ambience',
+ description: 'When enabled, plays various sounds depending on the area of the station you are in.',
+ component: CheckboxInput,
+};
+
+export const sound_announcements: FeatureToggle = {
+ name: 'Enable announcement sounds',
+ category: 'SOUND',
+ subcategory: 'IC',
+ description: 'When enabled, hear sounds for command reports, notices, etc.',
+ component: CheckboxInput,
+};
+
+export const sound_instruments: FeatureToggle = {
+ name: 'Enable instruments',
+ category: 'SOUND',
+ subcategory: 'IC',
+ description: 'When enabled, be able hear instruments in game.',
+ component: CheckboxInput,
+};
+
+export const sound_lobby: FeatureToggle = {
+ name: 'Enable lobby music',
+ category: 'SOUND',
+ subcategory: 'Music',
+ component: CheckboxInput,
+};
+
+export const sound_midi: FeatureToggle = {
+ name: 'Enable admin music',
+ category: 'SOUND',
+ subcategory: 'Music',
+ description: 'When enabled, admins will be able to play music to you.',
+ component: CheckboxInput,
+};
+
+export const sound_prayers: FeatureToggle = {
+ name: 'Enable prayer sounds',
+ category: 'ADMIN',
+ subcategory: 'Sound',
+ component: CheckboxInput,
+};
+
+export const sound_adminalert: FeatureToggle = {
+ name: 'Enable admin alert sounds',
+ category: 'ADMIN',
+ subcategory: 'Sound',
+ description: 'Enables sound on various admin notifications such as midround and event triggers.',
+ component: CheckboxInput,
+};
+
+export const sound_ship_ambience: FeatureToggle = {
+ name: 'Enable ship ambience',
+ category: 'SOUND',
+ subcategory: 'Ambience',
+ description: "Plays a soft droning sound, like that of a ship's engine.",
+ component: CheckboxInput,
+};
+
+export const sound_soundtrack: FeatureToggle = {
+ name: 'Enable soundtrack music',
+ category: 'SOUND',
+ subcategory: 'Music',
+ description:
+ 'When enabled, hear automatic soundtrack music triggered during situations like nuclear countdowns or xenomorph invasions.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx
new file mode 100644
index 0000000000000..cdaf85c08dd42
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx
@@ -0,0 +1,65 @@
+import { CheckboxInput, FeatureToggle } from '../base';
+
+export const tgui_fancy: FeatureToggle = {
+ name: 'Enable fancy tgui',
+ category: 'UI',
+ subcategory: 'TGUI',
+ description: 'Makes tgui windows look better, at the cost of compatibility.',
+ component: CheckboxInput,
+};
+
+export const tgui_lock: FeatureToggle = {
+ name: 'Lock tgui to main monitor',
+ category: 'UI',
+ subcategory: 'TGUI',
+ description: 'Locks tgui windows to your main monitor.',
+ component: CheckboxInput,
+};
+
+export const tgui_say_show_prefix: FeatureToggle = {
+ name: 'Keep Radio Prefix',
+ category: 'UI',
+ subcategory: 'TGUI Say',
+ description: 'If radio prefixes should remain in the chatbox after being typed.',
+ component: CheckboxInput,
+};
+
+export const tgui_input: FeatureToggle = {
+ name: 'Enable TGUI Input',
+ category: 'UI',
+ subcategory: 'TGUI Input',
+ description: 'Renders input boxes in TGUI. If this is disabled, legacy input boxes will be uesd.',
+ component: CheckboxInput,
+};
+
+export const tgui_input_large: FeatureToggle = {
+ name: 'Large Buttons',
+ category: 'UI',
+ subcategory: 'TGUI Input',
+ description: 'Makes TGUI buttons fill the size of the box.',
+ component: CheckboxInput,
+};
+
+export const tgui_input_swapped: FeatureToggle = {
+ name: 'Swap Submit/Cancel buttons',
+ category: 'UI',
+ subcategory: 'TGUI Input',
+ description: 'Switches the location of the Submit and Cancel buttons. "On" means Submit will be on the left.',
+ component: CheckboxInput,
+};
+
+export const tgui_say: FeatureToggle = {
+ name: 'Enable TGUI Say',
+ category: 'UI',
+ subcategory: 'TGUI Say',
+ description: 'Renders the Say input in TGUI. If disabled, a legacy input box will be used.',
+ component: CheckboxInput,
+};
+
+export const tgui_say_light_mode: FeatureToggle = {
+ name: 'Light Mode',
+ category: 'UI',
+ subcategory: 'TGUI Say',
+ description: 'Sets TGUI Say to use light mode.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tooltips.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tooltips.tsx
new file mode 100644
index 0000000000000..3ce06c0bd999a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tooltips.tsx
@@ -0,0 +1,22 @@
+import { multiline } from 'common/string';
+import { CheckboxInput, Feature, FeatureNumberInput, FeatureToggle } from '../base';
+
+export const enable_tips: FeatureToggle = {
+ name: 'Enable tooltips',
+ category: 'UI',
+ subcategory: 'Tooltips',
+ description: multiline`
+ Do you want to see tooltips when hovering over items?
+ `,
+ component: CheckboxInput,
+};
+
+export const tip_delay: Feature = {
+ name: 'Tooltip delay (in milliseconds)',
+ category: 'UI',
+ subcategory: 'Tooltips',
+ description: multiline`
+ How long should it take to see a tooltip when hovering over items?
+ `,
+ component: FeatureNumberInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ui_style.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ui_style.tsx
new file mode 100644
index 0000000000000..013ea7f984b84
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ui_style.tsx
@@ -0,0 +1,69 @@
+import { classes } from 'common/react';
+import { FeatureChoiced, FeatureChoicedServerData, FeatureValueProps, sortChoices, FeatureToggle, CheckboxInput } from '../base';
+import { Box, Dropdown, Stack } from '../../../../../components';
+
+const UIStyleInput = (props: FeatureValueProps) => {
+ const { serverData, value } = props;
+ if (!serverData) {
+ return null;
+ }
+
+ const { icons } = serverData;
+
+ if (!icons) {
+ return ui_style had no icons!;
+ }
+
+ const choices = Object.fromEntries(
+ Object.entries(icons).map(([name, icon]) => {
+ return [
+ name,
+
+
+
+
+
+ {name}
+ ,
+ ];
+ })
+ );
+
+ return (
+ {
+ return {
+ displayText: label,
+ value: dataValue,
+ };
+ })}
+ />
+ );
+};
+
+export const ui_style: FeatureChoiced = {
+ name: 'HUD Style',
+ category: 'UI',
+ subcategory: 'HUD',
+ component: UIStyleInput,
+};
+
+export const intent_style: FeatureToggle = {
+ name: 'Enable intent hotclick',
+ category: 'UI',
+ subcategory: 'HUD',
+ description:
+ 'Clicking on intents will directly select if this is on, otherwise clicking them will rotate the selection clockwise.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/window_flashing.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/window_flashing.tsx
new file mode 100644
index 0000000000000..66ec47b65f663
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/window_flashing.tsx
@@ -0,0 +1,13 @@
+import { multiline } from 'common/string';
+import { CheckboxInput, FeatureToggle } from '../base';
+
+export const windowflashing: FeatureToggle = {
+ name: 'Enable window flashing',
+ category: 'UI',
+ subcategory: 'Misc',
+ description: multiline`
+ When toggled, some important events will make your game icon flash on your
+ task tray.
+ `,
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/index.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/index.ts
new file mode 100644
index 0000000000000..d8fa77f496bd5
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/index.ts
@@ -0,0 +1,23 @@
+// Unlike species and others, feature files export arrays of features
+// rather than individual ones. This is because a lot of features are
+// extremely small, and so it's easier for everyone to just combine them
+// together.
+// This still helps to prevent the server from needing to send client UI data
+import { Feature } from './base';
+
+// while also preventing downstreams from needing to mutate existing files.
+const features: Record> = {};
+
+const requireFeature = require.context('./', true, /.tsx$/);
+
+for (const key of requireFeature.keys()) {
+ if (key === 'index' || key === 'base') {
+ continue;
+ }
+
+ for (const [featureKey, feature] of Object.entries(requireFeature(key))) {
+ features[featureKey] = feature as Feature;
+ }
+}
+
+export default features;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/randomization.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/randomization.tsx
new file mode 100644
index 0000000000000..bd1a65761fe8e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/randomization.tsx
@@ -0,0 +1,74 @@
+import { useBackend } from '../../../../backend';
+import { Button, Stack } from '../../../../components';
+import { PreferencesMenuData, RandomSetting } from '../../data';
+import { RandomizationButton } from '../../RandomizationButton';
+import { useRandomToggleState } from '../../useRandomToggleState';
+import { Feature } from './base';
+
+export const body_is_always_random: Feature = {
+ name: 'Random Body',
+ component: (props, context) => {
+ const [randomToggle, setRandomToggle] = useRandomToggleState(context);
+
+ return (
+
+
+ props.handleSetValue(newValue)} value={props.value} />
+
+
+ {randomToggle ? (
+ <>
+
+ {
+ props.act('randomize_character');
+ setRandomToggle(false);
+ }}>
+ Randomize
+
+
+
+
+ setRandomToggle(false)}>
+ Cancel
+
+
+ >
+ ) : (
+
+ setRandomToggle(true)}>Randomize
+
+ )}
+
+ );
+ },
+};
+
+export const name_is_always_random: Feature = {
+ name: 'Random Name',
+ component: (props, context) => {
+ return props.handleSetValue(value)} value={props.value} />;
+ },
+};
+
+export const random_species: Feature = {
+ name: 'Random Species',
+ component: (props, context) => {
+ const { act, data } = useBackend(context);
+
+ const species = data.character_preferences.randomization['species'];
+
+ return (
+
+ act('set_random_preference', {
+ preference: 'species',
+ value: newValue,
+ })
+ }
+ value={species || RandomSetting.Disabled}
+ />
+ );
+ },
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/gender.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/gender.ts
new file mode 100644
index 0000000000000..d757c47aa4874
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/gender.ts
@@ -0,0 +1,22 @@
+export enum Gender {
+ Male = 'male',
+ Female = 'female',
+ Other = 'plural',
+}
+
+export const GENDERS = {
+ [Gender.Male]: {
+ icon: 'male',
+ text: 'Male',
+ },
+
+ [Gender.Female]: {
+ icon: 'female',
+ text: 'Female',
+ },
+
+ [Gender.Other]: {
+ icon: 'tg-non-binary',
+ text: 'Other',
+ },
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/useRandomToggleState.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/useRandomToggleState.ts
new file mode 100644
index 0000000000000..67d0319503778
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/useRandomToggleState.ts
@@ -0,0 +1,3 @@
+import { useLocalState } from '../../backend';
+
+export const useRandomToggleState = (context) => useLocalState(context, 'randomToggle', false);
diff --git a/tgui/packages/tgui/interfaces/ScannerGate.js b/tgui/packages/tgui/interfaces/ScannerGate.js
index 9bc0bb8f7cb54..a175638a59b08 100644
--- a/tgui/packages/tgui/interfaces/ScannerGate.js
+++ b/tgui/packages/tgui/interfaces/ScannerGate.js
@@ -1,5 +1,5 @@
import { useBackend } from '../backend';
-import { Box, Button, LabeledList, NumberInput, Section } from '../components';
+import { Box, Button, LabeledList, Section, NumberInput } from '../components';
import { InterfaceLockNoticeBox } from './common/InterfaceLockNoticeBox';
import { Window } from '../layouts';
diff --git a/tgui/packages/tgui/interfaces/Telecomms.js b/tgui/packages/tgui/interfaces/Telecomms.js
index 24a6a169bed80..f52a570065100 100644
--- a/tgui/packages/tgui/interfaces/Telecomms.js
+++ b/tgui/packages/tgui/interfaces/Telecomms.js
@@ -1,5 +1,3 @@
-import { map, sortBy } from 'common/collections';
-import { flow } from 'common/fp';
import { useBackend } from '../backend';
import { Button, Input, LabeledList, Section, Table, NoticeBox, NumberInput, LabeledControls, Box } from '../components';
import { RADIO_CHANNELS } from '../constants';
diff --git a/tgui/packages/tgui/layouts/Window.js b/tgui/packages/tgui/layouts/Window.js
index b0707ffb9a3d8..7960006f2bab0 100644
--- a/tgui/packages/tgui/layouts/Window.js
+++ b/tgui/packages/tgui/layouts/Window.js
@@ -9,7 +9,7 @@ import { useDispatch } from 'common/redux';
import { decodeHtmlEntities, toTitleCase } from 'common/string';
import { Component } from 'inferno';
import { backendSuspendStart, useBackend } from '../backend';
-import { Icon, Flex } from '../components';
+import { Icon } from '../components';
import { UI_DISABLED, UI_INTERACTIVE, UI_UPDATE } from '../constants';
import { useDebug } from '../debug';
import { toggleKitchenSink } from '../debug/actions';
diff --git a/tgui/packages/tgui/stories/Popper.stories.js b/tgui/packages/tgui/stories/Popper.stories.js
index 652cee92877c3..08fd430fb2757 100644
--- a/tgui/packages/tgui/stories/Popper.stories.js
+++ b/tgui/packages/tgui/stories/Popper.stories.js
@@ -1,4 +1,3 @@
-import { Component, forwardRef } from 'inferno';
import { Box, Popper } from '../components';
export const meta = {
diff --git a/tgui/packages/tgui/stories/Tooltip.stories.js b/tgui/packages/tgui/stories/Tooltip.stories.js
index bba37c417bac9..306929ba767a5 100644
--- a/tgui/packages/tgui/stories/Tooltip.stories.js
+++ b/tgui/packages/tgui/stories/Tooltip.stories.js
@@ -4,7 +4,6 @@
* @license MIT
*/
-import { Placement } from '@popperjs/core';
import { Box, Button, Section, Tooltip } from '../components';
export const meta = {
diff --git a/tgui/packages/tgui/styles/atomic/centered-image.scss b/tgui/packages/tgui/styles/atomic/centered-image.scss
new file mode 100644
index 0000000000000..cce5bfdf2c110
--- /dev/null
+++ b/tgui/packages/tgui/styles/atomic/centered-image.scss
@@ -0,0 +1,7 @@
+.centered-image {
+ position: absolute;
+ height: 100%;
+ left: 50%;
+ top: 50%;
+ transform: translateX(-50%) translateY(-50%) scale(0.8);
+}
diff --git a/tgui/packages/tgui/styles/atomic/fit-text.scss b/tgui/packages/tgui/styles/atomic/fit-text.scss
new file mode 100644
index 0000000000000..1d49e13c8e35b
--- /dev/null
+++ b/tgui/packages/tgui/styles/atomic/fit-text.scss
@@ -0,0 +1,14 @@
+$mqIterations: 19;
+@mixin fontResize($iterations) {
+ $i: 1;
+ @while $i <= $iterations {
+ @media all and (min-width: 100px * $i) {
+ .fit-text {
+ font-size: 0.1em * $i;
+ }
+ }
+ $i: $i + 1;
+ }
+}
+
+@include fontResize($mqIterations);
diff --git a/tgui/packages/tgui/styles/atomic/loading.scss b/tgui/packages/tgui/styles/atomic/loading.scss
new file mode 100644
index 0000000000000..7dbc670cc1969
--- /dev/null
+++ b/tgui/packages/tgui/styles/atomic/loading.scss
@@ -0,0 +1,35 @@
+.loading-one {
+ opacity: 0;
+ animation: dot 0.6s infinite;
+ animation-delay: 0s;
+}
+
+.loading-two {
+ opacity: 0;
+ animation: dot 0.6s infinite;
+ animation-delay: 0.1s;
+}
+
+.loading-three {
+ opacity: 0;
+ animation: dot 0.6s infinite;
+ animation-delay: 0.2s;
+}
+
+@-webkit-keyframes dot {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes dot {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
diff --git a/tgui/packages/tgui/styles/atomic/section-background.scss b/tgui/packages/tgui/styles/atomic/section-background.scss
new file mode 100644
index 0000000000000..f93b5139cb5e4
--- /dev/null
+++ b/tgui/packages/tgui/styles/atomic/section-background.scss
@@ -0,0 +1,10 @@
+@use 'sass:color';
+@use '../base.scss';
+@use '../functions.scss';
+
+$background-color: base.$color-bg-section !default;
+
+.section-background {
+ background-color: functions.fake-alpha($background-color, base.$color-bg);
+ background-color: $background-color;
+}
diff --git a/tgui/packages/tgui/styles/colors.scss b/tgui/packages/tgui/styles/colors.scss
index aca122de1fc08..d1a9fc4fd9de9 100644
--- a/tgui/packages/tgui/styles/colors.scss
+++ b/tgui/packages/tgui/styles/colors.scss
@@ -22,6 +22,7 @@ $purple: #a333c8 !default;
$pink: #e03997 !default;
$brown: #a5673f !default;
$grey: #767676 !default;
+$light-grey: #aaa !default;
$primary: #4972a1 !default;
$good: #5baa27 !default;
@@ -58,6 +59,7 @@ $_gen_map: (
'pink': $pink,
'brown': $brown,
'grey': $grey,
+ 'light-grey': $light-grey,
'good': $good,
'average': $average,
'bad': $bad,
diff --git a/tgui/packages/tgui/styles/components/ColorSelectBox.scss b/tgui/packages/tgui/styles/components/ColorSelectBox.scss
new file mode 100644
index 0000000000000..06c9cabbe4a63
--- /dev/null
+++ b/tgui/packages/tgui/styles/components/ColorSelectBox.scss
@@ -0,0 +1,53 @@
+@use 'sass:color';
+@use '../base.scss';
+@use '../colors.scss';
+@use '../functions.scss' as *;
+
+$color-default: color.adjust(base.$color-bg, $lightness: 10%) !default;
+$color-disabled: #0c0c0c !default;
+$color-selected: colors.bg(colors.$green) !default;
+
+.ColorSelectBox {
+ display: inline-block;
+ box-sizing: content-box;
+ height: 11px;
+ width: 11px;
+ border: 2px solid $color-default;
+
+ .ColorSelectBox--inner {
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+ border: 1px solid black;
+ background-color: black;
+ }
+
+ &:hover {
+ transition: color 0ms, border-color 0ms;
+ }
+
+ &:focus {
+ transition: color 100ms, border-color 100ms;
+ }
+
+ &:hover,
+ &:focus {
+ border-color: lighten($color-default, 30%);
+ }
+}
+
+.ColorSelectBox--selected {
+ border-color: $color-selected;
+ &:hover,
+ &:focus {
+ border-color: lighten($color-selected, 30%);
+ }
+}
+
+.ColorSelectBox--disabled {
+ border-color: $color-disabled;
+ &:hover,
+ &:focus {
+ border-color: $color-disabled;
+ }
+}
diff --git a/tgui/packages/tgui/styles/components/Dropdown.scss b/tgui/packages/tgui/styles/components/Dropdown.scss
index 66f357e9e1a82..bd670681e2be3 100644
--- a/tgui/packages/tgui/styles/components/Dropdown.scss
+++ b/tgui/packages/tgui/styles/components/Dropdown.scss
@@ -7,11 +7,12 @@
.Dropdown {
position: relative;
+ align-items: center;
}
.Dropdown__control {
- position: relative;
display: inline-block;
+ align-items: center;
font-family: Verdana, sans-serif;
font-size: base.em(12px);
width: base.em(100px);
@@ -23,34 +24,24 @@
float: right;
padding-left: 0.35em;
width: 1.2em;
- height: base.em(22px);
+ height: 100%;
border-left: base.em(1px) solid #000;
border-left: base.em(1px) solid rgba(0, 0, 0, 0.25);
}
.Dropdown__menu {
- position: absolute;
overflow-y: auto;
+ align-items: center;
z-index: 5;
- width: base.em(100px);
max-height: base.em(200px);
- overflow-y: scroll;
border-radius: 0 0 base.em(2px) base.em(2px);
color: #fff;
background-color: #000;
background-color: rgba(0, 0, 0, 0.75);
}
-.Dropdown__menu-noscroll {
- position: absolute;
- overflow-y: auto;
- z-index: 5;
- width: base.em(100px);
- max-height: base.em(200px);
- border-radius: 0 0 base.em(2px) base.em(2px);
- color: #fff;
- background-color: #000;
- background-color: rgba(0, 0, 0, 0.75);
+.Dropdown__menu-scroll {
+ overflow-y: scroll;
}
.Dropdown__menuentry {
@@ -74,7 +65,6 @@
.Dropdown__selected-text {
display: inline-block;
text-overflow: ellipsis;
- overflow: hidden;
white-space: nowrap;
height: base.em(17px);
width: calc(100% - 1.2em);
diff --git a/tgui/packages/tgui/styles/components/LabeledList.scss b/tgui/packages/tgui/styles/components/LabeledList.scss
index 411af74c9a13e..94adf932549d5 100644
--- a/tgui/packages/tgui/styles/components/LabeledList.scss
+++ b/tgui/packages/tgui/styles/components/LabeledList.scss
@@ -32,7 +32,6 @@
padding: 0.25em 0.5em;
border: 0;
text-align: left;
- vertical-align: baseline;
}
.LabeledList__label--nowrap {
diff --git a/tgui/packages/tgui/styles/interfaces/PreferencesMenu.scss b/tgui/packages/tgui/styles/interfaces/PreferencesMenu.scss
new file mode 100644
index 0000000000000..553aefcea68b6
--- /dev/null
+++ b/tgui/packages/tgui/styles/interfaces/PreferencesMenu.scss
@@ -0,0 +1,357 @@
+@use 'sass:color';
+@use 'sass:map';
+@use '../components/Button.scss';
+@use '../colors.scss';
+
+$department_map: (
+ 'Assistant': colors.$grey,
+ 'Captain': colors.fg(colors.$blue),
+ 'Cargo': colors.$brown,
+ 'Civilian': colors.$grey,
+ 'Command': colors.$yellow,
+ 'Security': colors.$red,
+ 'Engineering': #f1a839,
+ 'Medical': colors.$teal,
+ 'Science': colors.fg(colors.$purple),
+ 'Service': colors.$green,
+ 'Silicon': colors.$pink,
+);
+
+.PreferencesMenu {
+ &__Main {
+ .Preferences__standard-palette {
+ .ColorSelectBox {
+ height: 1.35em !important;
+ width: 1.35em !important;
+ }
+ display: inline-block;
+ .Button {
+ height: 25px !important;
+ width: 25px !important;
+ line-height: 25px !important;
+ }
+ }
+ font-size: 1.35rem;
+ }
+
+ &__Antags {
+ &__antagSelection {
+ $antagonist_bottom_padding: 10px;
+
+ margin-bottom: -$antagonist_bottom_padding;
+
+ @mixin animate-hover {
+ .antagonist-icon-parent .antagonist-icon {
+ &:hover {
+ transform: scale(1.3);
+ transition: transform 1s ease-out;
+ }
+ }
+ }
+
+ &__antagonist {
+ padding-bottom: $antagonist_bottom_padding;
+ padding-right: 20px;
+
+ &__per_character {
+ &--off {
+ .antagonist-icon-parent-per-character {
+ .antagonist-icon {
+ border-color: darken(colors.$red, 10%);
+ &:hover {
+ transition: border-color 0.1s ease-out;
+ border-color: darken(colors.$red, 5%);
+ }
+ }
+ }
+ }
+ &--on {
+ .antagonist-icon-parent-per-character {
+ .antagonist-icon {
+ border-color: darken(colors.$grey, 10%);
+ &:hover {
+ transition: border-color 0.1s ease-out;
+ border-color: darken(colors.$grey, 5%);
+ }
+ }
+ }
+ }
+
+ .antagonist-icon-parent-per-character {
+ z-index: 1;
+ opacity: 0.9;
+ overflow: visible;
+ position: relative;
+ height: 0;
+ width: 0;
+ padding: 0;
+ margin: 0;
+ left: 74px;
+ bottom: -64px;
+
+ .antagonist-icon {
+ border-style: solid;
+ border-radius: 50%;
+ border-width: 4px;
+ background-color: #222;
+
+ box-sizing: content-box;
+
+ height: 32px;
+ width: 32px;
+ text-align: center;
+ font-size: 20px;
+ vertical-align: middle;
+ line-height: 32px;
+ -ms-user-select: none;
+ user-select: none;
+ }
+ }
+ }
+
+ .antagonist-icon-parent {
+ border-style: solid;
+ border-radius: 50%;
+ border-width: 4px;
+ box-sizing: content-box;
+ overflow: hidden;
+ position: relative;
+
+ height: 96px;
+ width: 96px;
+
+ .antagonist-icon {
+ border-radius: 50%;
+ -ms-interpolation-mode: nearest-neighbor;
+ overflow: hidden;
+ transition: transform 0.1s ease-in;
+ }
+ }
+
+ &--off {
+ @include animate-hover;
+
+ .antagonist-icon-parent {
+ border-color: colors.$red;
+
+ .antagonist-icon {
+ opacity: 0.5;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+
+ &--banned {
+ .antagonist-icon-parent {
+ border-color: colors.$grey;
+ color: color.adjust(colors.$red, $lightness: 20%);
+ .antagonist-icon {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+
+ &--on {
+ @include animate-hover;
+
+ .antagonist-icon-parent {
+ border-color: colors.$green;
+ }
+
+ &--banned {
+ .antagonist-icon-parent {
+ border-color: colors.$grey;
+ color: color.adjust(colors.$green, $lightness: 40%);
+ .antagonist-icon {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+
+ &--grey {
+ .antagonist-icon-parent {
+ border-color: colors.$grey;
+ color: inherit;
+ .antagonist-icon {
+ opacity: 0.5;
+ }
+ }
+ }
+
+ .antagonist-banned-slash {
+ background: colors.$grey;
+
+ width: 100%;
+ height: 3px;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translateY(-50%) translateX(-50%) rotate(35deg);
+
+ opacity: 0.8;
+ }
+
+ .antagonist-overlay-text {
+ text-align: center;
+ text-shadow: 1px 1px 3px 2px #222;
+ font-size: 1.2rem;
+ z-index: 1;
+
+ .antagonist-overlay-text-hours {
+ font-size: 1.5rem;
+ font-weight: bold;
+ }
+
+ width: 100%;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translateY(-50%) translateX(-50%);
+ }
+ }
+ }
+ }
+
+ &__Jobs {
+ > * {
+ flex: 1;
+ }
+
+ &__departments {
+ @each $department-name, $color-value in $department_map {
+ &--#{$department-name} {
+ &.head {
+ background: $color-value;
+
+ .job-name {
+ font-weight: bold;
+ }
+ }
+
+ background: colors.fg($color-value);
+ border-bottom: 2px solid rgba(0, 0, 0, 0.3);
+ border-left: 2px solid rgba(0, 0, 0, 0.3);
+ border-right: 2px solid rgba(0, 0, 0, 0.3);
+ color: black;
+
+ > * {
+ height: calc(100% + 0.2em);
+ padding-bottom: 0.2em;
+ }
+
+ &:first-child {
+ border-top: 2px solid rgba(0, 0, 0, 0.3);
+ }
+
+ .options {
+ background: rgba(0, 0, 0, 0.2);
+ height: 100%;
+ }
+ }
+
+ &--Captain {
+ &.head {
+ .job-name {
+ font-size: 1.5em;
+ }
+ }
+
+ .job-name {
+ font-weight: bold;
+ }
+ }
+ }
+
+ &__priority {
+ color: black;
+ border-left: 1px solid #222;
+ border-right: none;
+ border-top: none;
+ border-bottom: none;
+ border-radius: 0 !important;
+ text-shadow: 0 0 1px black;
+ &--off {
+ background-color: lighten(colors.$grey, 10%) !important;
+ border-color: lighten(colors.$grey, 20%);
+ border-left: none;
+ }
+ &--low {
+ background-color: colors.$red !important;
+ color: black !important;
+ text-shadow: none;
+ }
+ &--medium {
+ background-color: colors.$yellow !important;
+ color: black !important;
+ text-shadow: none;
+ }
+ &--high {
+ background-color: lighten(colors.$green, 10%) !important;
+ color: black !important;
+ text-shadow: none;
+ }
+ &--disabled {
+ background-color: #444 !important;
+ color: white !important;
+ transition: ease-out 0.25s background-color;
+ text-shadow: 0 0 1px black;
+ &:hover {
+ background-color: #666 !important;
+ }
+ }
+ }
+ }
+
+ .job-name {
+ font-size: 1.25em;
+ padding: 3px;
+ }
+ }
+
+ &__Quirks {
+ &__QuirkList {
+ background-color: colors.$light-grey;
+ height: calc(90vh - 170px);
+ min-height: 100%;
+ overflow-y: scroll;
+
+ &__quirk {
+ background-color: colors.$white;
+ border-bottom: 1px solid black;
+ color: #111;
+ transition: background-color 0.1s ease-in;
+
+ $quality_map: (
+ 'positive': colors.$green,
+ 'neutral': colors.$white,
+ 'negative': colors.$red,
+ );
+
+ @each $quality, $color-value in $quality_map {
+ &--#{$quality} {
+ background-color: $color-value;
+ transition: background-color 0.1s ease-in;
+ }
+ }
+
+ &:hover {
+ background-color: colors.$grey;
+ transition: background-color 0.1s ease-out;
+
+ @each $quality, $color-value in $quality_map {
+ .PreferencesMenu__Quirks__QuirkList__quirk--#{$quality} {
+ background-color: color.scale($color-value, $lightness: -25%);
+ transition: background-color 0.1s ease-out;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tgui/packages/tgui/styles/layouts/Layout.scss b/tgui/packages/tgui/styles/layouts/Layout.scss
index ae2fea1a2e7a7..41c4394ff98a4 100644
--- a/tgui/packages/tgui/styles/layouts/Layout.scss
+++ b/tgui/packages/tgui/styles/layouts/Layout.scss
@@ -8,16 +8,20 @@
$scrollbar-color-multiplier: 1 !default;
+@mixin fancy-scrollbar($base-color, $color-multiplier) {
+ scrollbar-base-color: color.scale($base-color, $lightness: -25% * $color-multiplier);
+ scrollbar-face-color: color.scale($base-color, $lightness: 10% * $color-multiplier);
+ scrollbar-3dlight-color: color.scale($base-color, $lightness: 0% * $color-multiplier);
+ scrollbar-highlight-color: color.scale($base-color, $lightness: 0% * $color-multiplier);
+ scrollbar-track-color: color.scale($base-color, $lightness: -25% * $color-multiplier);
+ scrollbar-arrow-color: color.scale($base-color, $lightness: 50% * $color-multiplier);
+ scrollbar-shadow-color: color.scale($base-color, $lightness: 10% * $color-multiplier);
+}
+
.Layout,
.Layout * {
// Fancy scrollbar
- scrollbar-base-color: color.scale(base.$color-bg, $lightness: -25% * $scrollbar-color-multiplier);
- scrollbar-face-color: color.scale(base.$color-bg, $lightness: 10% * $scrollbar-color-multiplier);
- scrollbar-3dlight-color: color.scale(base.$color-bg, $lightness: 0% * $scrollbar-color-multiplier);
- scrollbar-highlight-color: color.scale(base.$color-bg, $lightness: 0% * $scrollbar-color-multiplier);
- scrollbar-track-color: color.scale(base.$color-bg, $lightness: -25% * $scrollbar-color-multiplier);
- scrollbar-arrow-color: color.scale(base.$color-bg, $lightness: 50% * $scrollbar-color-multiplier);
- scrollbar-shadow-color: color.scale(base.$color-bg, $lightness: 10% * $scrollbar-color-multiplier);
+ @include fancy-scrollbar(base.$color-bg, $scrollbar-color-multiplier);
}
.Layout__content {
diff --git a/tgui/packages/tgui/styles/layouts/PopupWindow.scss b/tgui/packages/tgui/styles/layouts/PopupWindow.scss
new file mode 100644
index 0000000000000..0043308986478
--- /dev/null
+++ b/tgui/packages/tgui/styles/layouts/PopupWindow.scss
@@ -0,0 +1,17 @@
+@use 'sass:color';
+@use '../base.scss';
+@use '../functions.scss' as *;
+@use './Layout.scss';
+
+.PopupWindow {
+ color: base.$color-fg;
+ background-color: base.$color-bg;
+ background-image: linear-gradient(to bottom, base.$color-bg-start 0%, base.$color-bg-end 100%);
+ @include Layout.fancy-scrollbar(base.$color-bg, Layout.$scrollbar-color-multiplier);
+ border: 1px solid color.adjust(base.$color-bg, $lightness: 7%);
+ box-shadow: 1px 1px 5px 2px rgba(0, 0, 0, 0.5);
+}
+
+.PopupWindow * {
+ @include Layout.fancy-scrollbar(base.$color-bg, Layout.$scrollbar-color-multiplier);
+}
diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss
index 24dd721e797cd..d56528563dbad 100644
--- a/tgui/packages/tgui/styles/main.scss
+++ b/tgui/packages/tgui/styles/main.scss
@@ -11,15 +11,20 @@
// Atomic classes
@include meta.load-css('./atomic/candystripe.scss');
+@include meta.load-css('./atomic/centered-image.scss');
@include meta.load-css('./atomic/color.scss');
@include meta.load-css('./atomic/debug-layout.scss');
+@include meta.load-css('./atomic/fit-text.scss');
+@include meta.load-css('./atomic/loading.scss');
@include meta.load-css('./atomic/outline.scss');
+@include meta.load-css('./atomic/section-background.scss');
@include meta.load-css('./atomic/text.scss');
// Components
@include meta.load-css('./components/BlockQuote.scss');
@include meta.load-css('./components/Button.scss');
@include meta.load-css('./components/ColorBox.scss');
+@include meta.load-css('./components/ColorSelectBox.scss');
@include meta.load-css('./components/Dimmer.scss');
@include meta.load-css('./components/Divider.scss');
@include meta.load-css('./components/Dropdown.scss');
@@ -50,6 +55,7 @@
@include meta.load-css('./interfaces/ModularFabricator.scss');
@include meta.load-css('./interfaces/OrbitalMap.scss');
@include meta.load-css('./interfaces/Paper.scss');
+@include meta.load-css('./interfaces/PreferencesMenu.scss');
@include meta.load-css('./interfaces/Roulette.scss');
@include meta.load-css('./interfaces/IntegratedCircuit.scss');
@include meta.load-css('./interfaces/Techweb.scss');
@@ -61,6 +67,7 @@
@include meta.load-css('./layouts/Layout.scss');
@include meta.load-css('./layouts/NtosHeader.scss');
@include meta.load-css('./layouts/NtosWindow.scss');
+@include meta.load-css('./layouts/PopupWindow.scss');
@include meta.load-css('./layouts/TitleBar.scss');
@include meta.load-css('./layouts/Window.scss');
diff --git a/tgui/packages/tgui/styles/themes/generic-yellow.scss b/tgui/packages/tgui/styles/themes/generic-yellow.scss
new file mode 100644
index 0000000000000..aaaa38af0a876
--- /dev/null
+++ b/tgui/packages/tgui/styles/themes/generic-yellow.scss
@@ -0,0 +1,53 @@
+@use 'sass:color';
+@use 'sass:meta';
+
+$generic: #484455;
+$accent: #4f56a5;
+$accent-2: #ffbf00;
+
+@use '../colors.scss' with (
+ $fg-map-keys: (),
+ $bg-map-keys: (),
+ $primary: $accent,
+);
+@use '../base.scss' with (
+ $color-bg: color.scale($generic, $lightness: -45%),
+ $border-radius: 2px,
+);
+
+.theme-generic-yellow {
+ // Components
+ @include meta.load-css(
+ '../components/Button.scss',
+ $with: ('color-default': $accent, 'color-transparent-text': rgba(227, 240, 255, 0.75))
+ );
+ @include meta.load-css(
+ '../components/ColorSelectBox.scss',
+ $with: ('color-default': color.scale($generic, $lightness: -20%))
+ );
+ @include meta.load-css(
+ '../components/ProgressBar.scss',
+ $with: ('color-default-fill': $accent, 'background-color': rgba(0, 0, 0, 0.5))
+ );
+ @include meta.load-css('../components/Section.scss');
+
+ @include meta.load-css('../components/Input.scss', $with: ('border-color': #7b86ff));
+
+ // Layouts
+ @include meta.load-css('../layouts/Layout.scss');
+ @include meta.load-css('../layouts/Window.scss');
+ @include meta.load-css(
+ '../layouts/TitleBar.scss',
+ $with: (
+ 'background-color': color.scale($generic, $lightness: -50%),
+ 'shadow-color': #ffff0021,
+ 'shadow-color-core': $accent-2,
+ 'shadow-core-height': 3px
+ )
+ );
+ @include meta.load-css('../layouts/PopupWindow.scss');
+
+ .Layout__content {
+ background-image: url('../../assets/bg-beestation.svg');
+ }
+}
diff --git a/tgui/packages/tgui/styles/themes/generic.scss b/tgui/packages/tgui/styles/themes/generic.scss
index 4849a9380b021..9898860a0c8fa 100644
--- a/tgui/packages/tgui/styles/themes/generic.scss
+++ b/tgui/packages/tgui/styles/themes/generic.scss
@@ -38,6 +38,7 @@ $border-color: #7b86ff;
@include meta.load-css('../layouts/Layout.scss');
@include meta.load-css('../layouts/Window.scss');
@include meta.load-css('../layouts/TitleBar.scss', $with: ('background-color': color.scale($generic, $lightness: -25%)));
+ @include meta.load-css('../layouts/PopupWindow.scss');
.Layout__content {
background-image: none;
diff --git a/tgui/yarn.lock b/tgui/yarn.lock
index c5d58d887ac93..bd6fec9691afd 100644
--- a/tgui/yarn.lock
+++ b/tgui/yarn.lock
@@ -4926,6 +4926,28 @@ __metadata:
languageName: node
linkType: hard
+"eslint-plugin-unused-imports@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "eslint-plugin-unused-imports@npm:2.0.0"
+ dependencies:
+ eslint-rule-composer: ^0.3.0
+ peerDependencies:
+ "@typescript-eslint/eslint-plugin": ^5.0.0
+ eslint: ^8.0.0
+ peerDependenciesMeta:
+ "@typescript-eslint/eslint-plugin":
+ optional: true
+ checksum: 8aa1e03e75da2a62a354065e0cb8fe370118c6f8d9720a32fe8c1da937de6adb81a4fed7d0d391d115ac9453b49029fb19f970d180a2cf3dba451fd4c20f0dc4
+ languageName: node
+ linkType: hard
+
+"eslint-rule-composer@npm:^0.3.0":
+ version: 0.3.0
+ resolution: "eslint-rule-composer@npm:0.3.0"
+ checksum: c2f57cded8d1c8f82483e0ce28861214347e24fd79fd4144667974cd334d718f4ba05080aaef2399e3bbe36f7d6632865110227e6b176ed6daa2d676df9281b1
+ languageName: node
+ linkType: hard
+
"eslint-scope@npm:5.1.1":
version: 5.1.1
resolution: "eslint-scope@npm:5.1.1"
@@ -10452,6 +10474,7 @@ __metadata:
eslint-config-prettier: ^8.8.0
eslint-plugin-react: ^7.32.2
eslint-plugin-sonarjs: ^0.18.0
+ eslint-plugin-unused-imports: ^2.0.0
file-loader: ^6.2.0
inferno: ^8.2.1
jest: ^29.5.0
|