"
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/topic.dm b/code/modules/admin/topic.dm
index 028544d4a5a7..10d7c5a9cf6c 100644
--- a/code/modules/admin/topic.dm
+++ b/code/modules/admin/topic.dm
@@ -28,10 +28,11 @@
var/mob/M = locate(href_list["afreeze"]) in GLOB.mob_list
if(!M || !M.client)
return
+
var/message
- if(M.client.prefs.afreeze)
+ if(M.client.afreeze)
to_chat(M, span_userdanger("You are no longer frozen."))
- M.client.prefs.afreeze = FALSE
+ M.client.afreeze = FALSE
M.client.show_popup_menus = TRUE
M.client.show_verb_panel = TRUE
M.notransform = FALSE
@@ -39,7 +40,7 @@
message = "[key_name(usr)] has unfrozen [key_name(M)]."
else
to_chat(M, span_userdanger("You have been frozen by an administrator."))
- M.client.prefs.afreeze = TRUE
+ M.client.afreeze = TRUE
M.client.show_popup_menus = FALSE
M.client.show_verb_panel = FALSE
M.notransform = TRUE
diff --git a/code/modules/admin/verbs/mapping.dm b/code/modules/admin/verbs/mapping.dm
index bb8bdb1c9b1d..f7d256dd3e59 100644
--- a/code/modules/admin/verbs/mapping.dm
+++ b/code/modules/admin/verbs/mapping.dm
@@ -350,7 +350,6 @@ GLOBAL_VAR_INIT(say_disabled, FALSE)
qdel(I)
randomize_human(D)
JB.equip(D, TRUE, FALSE)
- COMPILE_OVERLAYS(D)
var/icon/I = icon(getFlatIcon(D), frame = 1)
final.Insert(I, JB.title)
qdel(D)
diff --git a/code/modules/admin/verbs/one_click_antag.dm b/code/modules/admin/verbs/one_click_antag.dm
index ba928e795a68..bc7b90fe28b4 100644
--- a/code/modules/admin/verbs/one_click_antag.dm
+++ b/code/modules/admin/verbs/one_click_antag.dm
@@ -352,7 +352,6 @@
equipAntagOnDummy(mannequin, ert)
- COMPILE_OVERLAYS(mannequin)
CHECK_TICK
var/icon/preview_icon = icon('icons/effects/effects.dmi', "nothing")
preview_icon.Scale(48+32, 16+32)
@@ -451,7 +450,7 @@
//Spawn the body
var/mob/living/carbon/human/ERTOperative = new ertemplate.mobtype(spawnloc)
- chosen_candidate.client.prefs.copy_to(ERTOperative)
+ chosen_candidate.client.prefs.apply_prefs_to(ERTOperative)
ERTOperative.key = chosen_candidate.key
if(ertemplate.enforce_human || !(ERTOperative.dna.species.changesource_flags & ERT_SPAWN)) // Don't want any exploding plasmemes
@@ -562,7 +561,7 @@
//Spawn the body
var/mob/living/carbon/human/ERTOperative = new ertemplate.mobtype(spawnloc)
- chosen_candidate.client.prefs.copy_to(ERTOperative)
+ chosen_candidate.client.prefs.apply_prefs_to(ERTOperative)
ERTOperative.key = chosen_candidate.key
if(ertemplate.enforce_human || !(ERTOperative.dna.species.changesource_flags & ERT_SPAWN)) // Don't want any exploding plasmemes
diff --git a/code/modules/admin/verbs/randomverbs.dm b/code/modules/admin/verbs/randomverbs.dm
index 6635a81b0995..7e02ea347b44 100644
--- a/code/modules/admin/verbs/randomverbs.dm
+++ b/code/modules/admin/verbs/randomverbs.dm
@@ -432,9 +432,9 @@ 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"], null, record_found.fields["name"], record_found.fields["blood_type"], new record_found.fields["species"], record_found.fields["features"])
else
- var/datum/preferences/A = new()
- A.copy_to(new_character)
- A.real_name = G_found.real_name
+ new_character.randomize_human_appearance(~(RANDOMIZE_NAME|RANDOMIZE_SPECIES))
+ new_character.name = G_found.real_name
+ new_character.real_name = G_found.real_name
new_character.dna.update_dna_identity()
new_character.name = new_character.real_name
@@ -826,13 +826,13 @@ Traitors and the like can also be revived with the previous role mostly intact.
newview = input("Enter custom view range:","FUCK YEEEEE") as num
if(!newview)
return
- if(newview > 64)
+ if(newview >= 64)
if(alert("Warning: Setting your view range to that large size may cause horrendous lag, visual bugs, and/or game crashes. Are you sure?",,"Yes","No") != "Yes")
return
view_size.setTo(newview)
//yogs end
else
- view_size.resetToDefault(getScreenSize(prefs.widescreenpref))
+ view_size.resetToDefault(getScreenSize(prefs.read_preference(/datum/preference/toggle/widescreen)))
log_admin("[key_name(usr)] changed their view range to [view].")
//message_admins("\blue [key_name_admin(usr)] changed their view range to [view].") //why? removed by order of XSI
@@ -1509,8 +1509,7 @@ Traitors and the like can also be revived with the previous role mostly intact.
var/mob/M = usr
if(isobserver(M))
var/mob/living/carbon/human/H = new(T)
- var/datum/preferences/A = new
- A.copy_to(H)
+ H.randomize_human_appearance(~(RANDOMIZE_SPECIES))
H.dna.update_dna_identity()
H.equipOutfit(/datum/outfit/centcom/official/nopda)
@@ -1577,8 +1576,7 @@ Traitors and the like can also be revived with the previous role mostly intact.
var/mob/M = usr
if(isobserver(M))
var/mob/living/carbon/human/H = new(T)
- var/datum/preferences/A = new
- A.copy_to(H)
+ H.randomize_human_appearance(~(RANDOMIZE_SPECIES))
H.dna.update_dna_identity()
var/datum/mind/Mind = new /datum/mind(M.key) // Reusing the mob's original mind actually breaks objectives for any antag who had this person as their target.
diff --git a/code/modules/antagonists/_common/antag_datum.dm b/code/modules/antagonists/_common/antag_datum.dm
index 1d10ee85a68a..bfed056bb21d 100644
--- a/code/modules/antagonists/_common/antag_datum.dm
+++ b/code/modules/antagonists/_common/antag_datum.dm
@@ -25,6 +25,8 @@ GLOBAL_LIST_EMPTY(antagonists)
var/show_to_ghosts = FALSE // Should this antagonist be shown as antag to ghosts? Shouldn't be used for stealthy antagonists like traitors
/// The corporation employing us
var/datum/corporation/company
+ /// The typepath for the outfit to show in the preview for the preferences menu.
+ var/preview_outfit
/datum/antagonist/New()
@@ -220,6 +222,41 @@ GLOBAL_LIST_EMPTY(antagonists)
return FALSE
return TRUE
+/// 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/antagonist/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)
+ 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/antagonist/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/antagonist/proc/get_preview_icon()
+ if (isnull(preview_outfit))
+ return null
+
+ return finish_preview_icon(render_preview_outfit(preview_outfit))
+
// List if ["Command"] = CALLBACK(), user will be appeneded to callback arguments on execution
/datum/antagonist/proc/get_admin_commands()
. = list()
diff --git a/code/modules/antagonists/_common/antag_spawner.dm b/code/modules/antagonists/_common/antag_spawner.dm
index 629e087cbbbb..7d6a66f4a57e 100644
--- a/code/modules/antagonists/_common/antag_spawner.dm
+++ b/code/modules/antagonists/_common/antag_spawner.dm
@@ -88,7 +88,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.copy_to(M)
+ C.prefs.apply_prefs_to(M)
M.key = C.key
var/datum/mind/app_mind = M.mind
@@ -151,7 +151,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.copy_to(M)
+ C.prefs.apply_prefs_to(M)
M.key = C.key
var/datum/antagonist/nukeop/new_op = new()
@@ -170,7 +170,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.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()
diff --git a/code/modules/antagonists/abductor/abductor.dm b/code/modules/antagonists/abductor/abductor.dm
index b13ded9656e4..6a4f8c482bac 100644
--- a/code/modules/antagonists/abductor/abductor.dm
+++ b/code/modules/antagonists/abductor/abductor.dm
@@ -13,6 +13,26 @@
var/landmark_type
var/greet_text
+/datum/antagonist/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/antagonist/abductor/agent
name = "Abductor Agent"
diff --git a/code/modules/antagonists/blob/blob.dm b/code/modules/antagonists/blob/blob.dm
index a7efe42ffa11..80fcaaf6cbb6 100644
--- a/code/modules/antagonists/blob/blob.dm
+++ b/code/modules/antagonists/blob/blob.dm
@@ -41,6 +41,16 @@
to_chat(owner.current, "
You are no longer the Blob! ")
return ..()
+/datum/antagonist/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/antagonist/blob/proc/create_objectives()
var/datum/objective/blob_takeover/main = new
main.owner = owner
diff --git a/code/modules/antagonists/bloodsuckers/bloodsuckers.dm b/code/modules/antagonists/bloodsuckers/bloodsuckers.dm
index 89a18d7a4e34..dcacce04380c 100644
--- a/code/modules/antagonists/bloodsuckers/bloodsuckers.dm
+++ b/code/modules/antagonists/bloodsuckers/bloodsuckers.dm
@@ -836,7 +836,7 @@
var/mob/living/carbon/human/user = convertee.current
if(!(user.dna?.species) || !(user.mob_biotypes & MOB_ORGANIC))
user.set_species(/datum/species/human)
- user.apply_pref_name("human", user.client)
+ user.apply_pref_name(/datum/preference/name/real_name, user.client)
// Check for Fledgeling
if(converter)
message_admins("[convertee] has become a Bloodsucker, and was created by [converter].")
@@ -934,3 +934,10 @@
var/datum/atom_hud/antag/vamphud = GLOB.huds[ANTAG_HUD_BLOODSUCKER]
vamphud.leave_hud(owner.current)
set_antag_hud(owner.current, null)
+
+/datum/antagonist/bloodsucker/get_preview_icon()
+ var/icon/bloodsucker_icon = icon('icons/mob/bloodsucker_mobs.dmi', "batform")
+
+ bloodsucker_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return bloodsucker_icon
diff --git a/code/modules/antagonists/brother/brother.dm b/code/modules/antagonists/brother/brother.dm
index 1fa72c2d1330..768ed4565efc 100644
--- a/code/modules/antagonists/brother/brother.dm
+++ b/code/modules/antagonists/brother/brother.dm
@@ -43,6 +43,34 @@
/datum/antagonist/brother/antag_panel_data()
return "Conspirators : [get_brother_names()]"
+/datum/antagonist/brother/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/brother1 = new
+ var/mob/living/carbon/human/dummy/consistent/brother2 = new
+
+ brother1.dna.features["ethcolor"] = GLOB.color_list_ethereal["Faint Red"]
+ brother1.set_species(/datum/species/ethereal)
+
+ 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/antagonist/brother/proc/get_brother_names()
var/list/brothers = team.members - owner
var/brother_text = ""
diff --git a/code/modules/antagonists/changeling/changeling.dm b/code/modules/antagonists/changeling/changeling.dm
index d7099270836f..bf546dbd56fe 100644
--- a/code/modules/antagonists/changeling/changeling.dm
+++ b/code/modules/antagonists/changeling/changeling.dm
@@ -109,8 +109,8 @@
if(ishuman(C) && (NO_DNA_COPY in C.dna.species.species_traits || !C.has_dna()))
to_chat(C, span_userdanger("You have been made a human, as your original race had incompatible DNA."))
C.set_species(/datum/species/human, TRUE, TRUE)
- if(C.client?.prefs?.custom_names["human"] && !is_banned_from(C.client?.ckey, "Appearance"))
- C.fully_replace_character_name(C.dna.real_name, C.client.prefs.custom_names["human"])
+ if(C.client?.prefs?.read_preference(/datum/preference/name/real_name) && !is_banned_from(C.client?.ckey, "Appearance"))
+ C.fully_replace_character_name(C.dna.real_name, C.client.prefs.read_preference(/datum/preference/name/real_name))
else
C.fully_replace_character_name(C.dna.real_name, random_unique_name(C.gender))
@@ -625,3 +625,24 @@
/datum/antagonist/changeling/xenobio/antag_listing_name()
return ..() + "(Xenobio)"
+
+/datum/antagonist/changeling/get_preview_icon()
+ var/icon/final_icon = render_preview_outfit(/datum/outfit/changeling)
+ 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/changeling
+ name = "Changeling"
+
+ head = /obj/item/clothing/head/helmet/changeling
+ suit = /obj/item/clothing/suit/armor/changeling
+ l_hand = /obj/item/melee/arm_blade
diff --git a/code/modules/antagonists/changeling/powers/hivemind.dm b/code/modules/antagonists/changeling/powers/hivemind.dm
index 9e63c555426b..6784dbc4f945 100644
--- a/code/modules/antagonists/changeling/powers/hivemind.dm
+++ b/code/modules/antagonists/changeling/powers/hivemind.dm
@@ -59,7 +59,7 @@ GLOBAL_LIST_EMPTY(hivemind_bank)
to_chat(user, span_warning("The airwaves already have all of our DNA!"))
return
- var/chosen_name = input("Select a DNA to channel: ", "Channel DNA", null) as null|anything in sortList(names)
+ var/chosen_name = tgui_input_list(user, "Select a DNA to channel", "Channel DNA", sortList(names))
if(!chosen_name)
return
@@ -104,7 +104,7 @@ GLOBAL_LIST_EMPTY(hivemind_bank)
to_chat(user, span_warning("There's no new DNA to absorb from the air!"))
return
- var/S = input("Select a DNA absorb from the air: ", "Absorb DNA", null) as null|anything in sortList(names)
+ var/S = tgui_input_list(user, "Select a DNA absorb from the air", "Absorb DNA", sortList(names))
if(!S)
return
var/datum/changelingprofile/chosen_prof = names[S]
diff --git a/code/modules/antagonists/changeling/powers/humanform.dm b/code/modules/antagonists/changeling/powers/humanform.dm
index 3f517494f860..6c8f870524c2 100644
--- a/code/modules/antagonists/changeling/powers/humanform.dm
+++ b/code/modules/antagonists/changeling/powers/humanform.dm
@@ -15,7 +15,7 @@
for(var/datum/changelingprofile/prof in changeling.stored_profiles)
names += "[prof.name]"
- var/chosen_name = input("Select the target DNA: ", "Target DNA", null) as null|anything in names
+ var/chosen_name = tgui_input_list("Select the target DNA", "Target DNA", sortList(names))
if(!chosen_name)
return
diff --git a/code/modules/antagonists/clockcult/clockcult.dm b/code/modules/antagonists/clockcult/clockcult.dm
index 20ab3e2a8daa..862c4d2893b5 100644
--- a/code/modules/antagonists/clockcult/clockcult.dm
+++ b/code/modules/antagonists/clockcult/clockcult.dm
@@ -259,3 +259,10 @@
if(ratio >= SERVANT_HARDMODE_PERCENT)
GLOB.clockwork_hardmode_active = TRUE
hierophant_message("
As the cult increases in size, the Ark's connection to the material plane weakens. Warping with camera consoles will take substantially more time unless the destination is a clockwork tile! ")
+
+/datum/antagonist/clockcult/get_preview_icon()
+ var/icon/clockie_icon = icon('icons/effects/512x512.dmi', "ratvar")
+
+ clockie_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return clockie_icon
diff --git a/code/modules/antagonists/creep/creep.dm b/code/modules/antagonists/creep/creep.dm
index c59a29e94729..cc887dafe65c 100644
--- a/code/modules/antagonists/creep/creep.dm
+++ b/code/modules/antagonists/creep/creep.dm
@@ -2,6 +2,7 @@
name = "Obsessed"
show_in_antagpanel = TRUE
antagpanel_category = "Other"
+ preview_outfit = /datum/outfit/obsessed
job_rank = ROLE_OBSESSED
show_name_in_check_antagonists = TRUE
roundend_category = "obsessed"
@@ -21,6 +22,40 @@
//PRESTO FUCKIN MAJESTO
C.gain_trauma(/datum/brain_trauma/special/obsessed)//ZAP
+/datum/antagonist/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(preview_outfit)
+ 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/yogs/redoveralls
+ 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()
+
/datum/antagonist/obsessed/greet()
owner.current.playsound_local(get_turf(owner.current), 'sound/ambience/antag/creepalert.ogg', 100, FALSE, pressure_affected = FALSE)
to_chat(owner, span_boldannounce("You are the Obsessed!"))
diff --git a/code/modules/antagonists/cult/cult.dm b/code/modules/antagonists/cult/cult.dm
index d014109b7215..eac79a76e68c 100644
--- a/code/modules/antagonists/cult/cult.dm
+++ b/code/modules/antagonists/cult/cult.dm
@@ -11,6 +11,7 @@
var/datum/action/innate/cult/comm/communion = new
var/datum/action/innate/cult/mastervote/vote = new
var/datum/action/innate/cult/blood_magic/magic = new
+ preview_outfit = /datum/outfit/cultist
job_rank = ROLE_CULTIST
var/ignore_implant = FALSE
var/give_equipment = FALSE
@@ -83,6 +84,56 @@
if(cult_team.blood_target && cult_team.blood_target_image && current.client)
current.client.images += cult_team.blood_target_image
+/*
+/datum/antagonist/cult/get_preview_icon()
+ var/icon/icon = render_preview_outfit(preview_outfit)
+
+ // 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/antagonist/cult/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/cult1 = new
+ var/mob/living/carbon/human/dummy/consistent/cult2 = new
+
+ var/icon/final_icon = render_preview_outfit(/datum/outfit/cultist/leader, cult1)
+ var/icon/teammate = render_preview_outfit(/datum/outfit/cultist/follower, cult2)
+ teammate.Blend(rgb(128, 128, 128, 128), ICON_MULTIPLY)
+
+ final_icon.Blend(teammate, ICON_OVERLAY, -world.icon_size / 4, 0)
+ final_icon.Blend(teammate, ICON_OVERLAY, world.icon_size / 4, 0)
+
+ qdel(cult1)
+ qdel(cult2)
+
+ return finish_preview_icon(final_icon)
+
+/datum/outfit/cultist/leader
+ suit = /obj/item/clothing/suit/hooded/cultrobes/berserker
+ shoes = /obj/item/clothing/shoes/cult/alt
+ head = /obj/item/clothing/head/hooded/berserkerhood
+ glasses = /obj/item/clothing/glasses/hud/health/night/cultblind
+ r_hand = /obj/item/melee/cultblade
+ l_hand = /obj/item/shield/mirror
+
+/datum/outfit/cultist/follower
+ suit = /obj/item/clothing/suit/cultrobes/alt
+ shoes = /obj/item/clothing/shoes/cult/alt
+ head = /obj/item/clothing/head/culthood/alt
+ glasses = /obj/item/clothing/glasses/hud/health/night/cultblind
+ r_hand = /obj/item/melee/cultblade
/datum/antagonist/cult/proc/equip_cultist(metal=TRUE)
var/mob/living/carbon/H = owner.current
@@ -522,3 +573,20 @@
/datum/team/cult/is_gamemode_hero()
return SSticker.mode.name == "cult"
+
+/datum/outfit/cultist
+ name = "Cultist (Preview only)"
+
+ uniform = /obj/item/clothing/under/color/black
+ suit = /obj/item/clothing/suit/yogs/armor/sith_suit
+ shoes = /obj/item/clothing/shoes/cult/alt
+ r_hand = /obj/item/melee/blood_magic/stun
+
+/datum/outfit/cultist/post_equip(mob/living/carbon/human/H, visualsOnly)
+ H.eye_color = BLOODCULT_EYE
+ H.update_body()
+
+ var/obj/item/clothing/suit/hooded/hooded = locate() in H
+ if(!isdummy(H))
+ hooded.MakeHood() // This is usually created on Initialize, but we run before atoms
+ hooded.ToggleHood()
diff --git a/code/modules/antagonists/demon/demons.dm b/code/modules/antagonists/demon/demons.dm
index d1fccba1c73d..3e737527468a 100644
--- a/code/modules/antagonists/demon/demons.dm
+++ b/code/modules/antagonists/demon/demons.dm
@@ -180,3 +180,10 @@
#undef SIN_PRIDE
#undef SIN_SLOTH
#undef SIN_WRATH
+
+/datum/antagonist/sinfuldemon/get_preview_icon()
+ var/icon/sinfuldemon_icon = icon('icons/mob/mob.dmi', "lesserdaemon")
+
+ sinfuldemon_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return sinfuldemon_icon
diff --git a/code/modules/antagonists/devil/devil.dm b/code/modules/antagonists/devil/devil.dm
index d55a03b0fb41..d243aeff5fa5 100644
--- a/code/modules/antagonists/devil/devil.dm
+++ b/code/modules/antagonists/devil/devil.dm
@@ -214,13 +214,10 @@ GLOBAL_LIST_INIT(devil_suffix, list(" the Red", " the Soulless", " the Master",
/datum/antagonist/devil/proc/regress_humanoid()
to_chat(owner.current, span_warning("Your powers weaken, have more contracts be signed to regain power."))
if(ishuman(owner.current))
- var/species_to_be
var/mob/living/carbon/human/H = owner.current
- if(!isnull(owner.current.client.prefs.pref_species))
- species_to_be = owner.current.client.prefs.pref_species //fixes a really stupid bug where devils would turn into out of place looking humans after getting detransformed
- else
- species_to_be = /datum/species/human
- H.set_species(species_to_be, 1)
+
+ var/species_type = owner.current.client.prefs.read_preference(/datum/preference/choiced/species)
+ H.set_species(species_type, 1)
H.regenerate_icons()
give_appropriate_spells()
if(istype(owner.current.loc, /obj/effect/dummy/phased_mob))
@@ -575,3 +572,10 @@ GLOBAL_LIST_INIT(devil_suffix, list(" the Red", " the Soulless", " the Master",
ban = randomdevilban()
banish = randomdevilbanish()
ascendable = prob(25)
+
+/datum/antagonist/devil/get_preview_icon()
+ var/icon/devil_icon = icon('icons/effects/64x64.dmi', "devil")
+
+ devil_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return devil_icon
diff --git a/code/modules/antagonists/disease/disease_datum.dm b/code/modules/antagonists/disease/disease_datum.dm
index 7c4cbd9c3a26..fb18acc65d00 100644
--- a/code/modules/antagonists/disease/disease_datum.dm
+++ b/code/modules/antagonists/disease/disease_datum.dm
@@ -75,6 +75,11 @@
return result.Join("
")
+/datum/antagonist/disease/get_preview_icon()
+ var/icon/icon = icon('icons/mob/hud.dmi', "virus_infected")
+ icon.Blend(COLOR_GREEN_GRAY, ICON_MULTIPLY)
+ icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+ return icon
/datum/objective/disease_infect
explanation_text = "Survive and infect as many people as possible."
@@ -102,3 +107,9 @@
if(L.onCentCom() || L.onSyndieBase())
return TRUE
return FALSE
+
+/datum/antagonist/disease/get_preview_icon()
+ var/icon/disease_icon = icon('icons/mob/hud.dmi', "infected")
+ disease_icon.Blend(COLOR_GREEN_GRAY, ICON_MULTIPLY)
+ disease_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+ return disease_icon
diff --git a/code/modules/antagonists/disease/disease_mob.dm b/code/modules/antagonists/disease/disease_mob.dm
index 0ea7055431b4..13cf0085f31d 100644
--- a/code/modules/antagonists/disease/disease_mob.dm
+++ b/code/modules/antagonists/disease/disease_mob.dm
@@ -126,7 +126,7 @@ the new instance inside the host to be updated to the template's stats.
else
link = ""
// Create map text prior to modifying message for goonchat
- if (client?.prefs.chat_on_map && (client.prefs.see_chat_non_mob || ismob(speaker)))
+ if (client?.prefs.read_preference(/datum/preference/toggle/enable_runechat) && (client.prefs.read_preference(/datum/preference/toggle/enable_runechat_non_mobs) || ismob(speaker)))
create_chat_message(speaker, message_language, raw_message, spans)
// Recompose the message, because it's scrambled by default
message = compose_message(speaker, message_language, raw_message, radio_freq, spans, message_mods)
diff --git a/code/modules/antagonists/eldritch_cult/eldritch_antag.dm b/code/modules/antagonists/eldritch_cult/eldritch_antag.dm
index 6a7e55e0f8e6..cf93ce107723 100644
--- a/code/modules/antagonists/eldritch_cult/eldritch_antag.dm
+++ b/code/modules/antagonists/eldritch_cult/eldritch_antag.dm
@@ -5,6 +5,7 @@
antag_moodlet = /datum/mood_event/heretics
job_rank = ROLE_HERETIC
can_hijack = HIJACK_HIJACKER
+ preview_outfit = /datum/outfit/heretic
var/give_equipment = TRUE
var/list/researched_knowledge = list()
var/list/transmutations = list()
@@ -41,6 +42,27 @@
You can find a basic guide at : https://wiki.yogstation.net/wiki/Heretic
\
If you need to quickly check your unlocked transmutation recipes, alt+click your Codex Cicatrix.")
+/datum/antagonist/heretic/get_preview_icon()
+ var/icon/icon = render_preview_outfit(preview_outfit)
+
+ // MOTHBLOCKS TOOD: Copied and pasted from cult, make this its own proc
+
+ // 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/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/antagonist/heretic/on_gain()
var/mob/living/current = owner.current
if(ishuman(current))
@@ -277,3 +299,14 @@
if(!cultie)
return FALSE
return cultie.total_sacrifices >= 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/H, visualsOnly)
+ var/obj/item/clothing/suit/hooded/hooded = locate() in H
+ hooded.MakeHood() // This is usually created on Initialize, but we run before atoms
+ hooded.ToggleHood()
diff --git a/code/modules/antagonists/fugitive/fugitive.dm b/code/modules/antagonists/fugitive/fugitive.dm
index cfc736bf9d8e..b228bd53dca9 100644
--- a/code/modules/antagonists/fugitive/fugitive.dm
+++ b/code/modules/antagonists/fugitive/fugitive.dm
@@ -7,6 +7,7 @@
var/datum/team/fugitive/fugitive_team
var/is_captured = FALSE
var/backstory = "error"
+ preview_outfit = /datum/outfit/spacepol
/datum/antagonist/fugitive/apply_innate_effects(mob/living/mob_override)
var/mob/living/M = mob_override || owner.current
@@ -125,3 +126,4 @@
else if(M in GLOB.dead_mob_list)
to_chat(M, "[FOLLOW_LINK(M, user)] [my_message]")
user.log_talk(message, LOG_SAY, tag="Yalp Elor")
+
diff --git a/code/modules/antagonists/hivemind/hivemind.dm b/code/modules/antagonists/hivemind/hivemind.dm
index bf32a0efeb0e..f6e134f73bc9 100644
--- a/code/modules/antagonists/hivemind/hivemind.dm
+++ b/code/modules/antagonists/hivemind/hivemind.dm
@@ -296,3 +296,10 @@
/datum/antagonist/hivemind/is_gamemode_hero()
return SSticker.mode.name == "Assimilation"
+
+/datum/antagonist/hivemind/get_preview_icon()
+ var/icon/hivemind_icon = icon('icons/mob/hivebot.dmi', "basic")
+
+ hivemind_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return hivemind_icon
diff --git a/code/modules/antagonists/horror/horror.dm b/code/modules/antagonists/horror/horror.dm
index 745839b91348..70ec3d113a63 100644
--- a/code/modules/antagonists/horror/horror.dm
+++ b/code/modules/antagonists/horror/horror.dm
@@ -859,3 +859,4 @@
else
RemoveInfestActions()
GrantHorrorActions()
+
diff --git a/code/modules/antagonists/horror/horror_datums.dm b/code/modules/antagonists/horror/horror_datums.dm
index 1d135f94d3fc..a6342c23738a 100644
--- a/code/modules/antagonists/horror/horror_datums.dm
+++ b/code/modules/antagonists/horror/horror_datums.dm
@@ -340,3 +340,10 @@
to_chat(src, span_userdanger("With an immense exertion of will, you regain control of your body!"))
to_chat(H.victim, span_danger("You feel control of the host brain ripped from your grasp, and retract your probosci before the wild neural impulses can damage you."))
H.detatch()
+
+/datum/antagonist/horror/get_preview_icon()
+ var/icon/horror_icon = icon('icons/mob/animal.dmi', "horror_preview")
+
+ horror_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return horror_icon
diff --git a/code/modules/antagonists/malf/malf.dm b/code/modules/antagonists/malf/malf.dm
index 1d6a75c669cc..06f6f11c0be5 100644
--- a/code/modules/antagonists/malf/malf.dm
+++ b/code/modules/antagonists/malf/malf.dm
@@ -1,7 +1,7 @@
/datum/antagonist/traitor/malf //inheriting traitor antag datum since traitor AIs use it.
malf = TRUE
roundend_category = "malfunctioning AIs"
- name = "Malf"
+ name = "Malfunctioning AI"
show_to_ghosts = TRUE
/datum/antagonist/traitor/malf/forge_ai_objectives()
@@ -14,3 +14,13 @@
/datum/antagonist/traitor/malf/can_be_owned(datum/mind/new_owner)
return istype(new_owner.current, /mob/living/silicon/ai)
+
+/datum/antagonist/traitor/malf/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
diff --git a/code/modules/antagonists/monkey/monkey.dm b/code/modules/antagonists/monkey/monkey.dm
index 91a8bf5fe89d..19c134750402 100644
--- a/code/modules/antagonists/monkey/monkey.dm
+++ b/code/modules/antagonists/monkey/monkey.dm
@@ -18,6 +18,16 @@
/datum/antagonist/monkey/get_team()
return monkey_team
+/datum/antagonist/monkey/get_preview_icon()
+ // Creating a *real* monkey is fairly involved before atoms init.
+ var/icon/icon = icon('icons/mob/monkey.dmi', "monkey1")
+
+ icon.Crop(4, 9, 28, 33)
+ icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+ icon.Shift(SOUTH, 10)
+
+ return icon
+
/datum/antagonist/monkey/on_gain()
. = ..()
SSticker.mode.ape_infectees += owner
diff --git a/code/modules/antagonists/monsterhunter/monsterhunter.dm b/code/modules/antagonists/monsterhunter/monsterhunter.dm
index 8cd0a157236b..e73529574a35 100644
--- a/code/modules/antagonists/monsterhunter/monsterhunter.dm
+++ b/code/modules/antagonists/monsterhunter/monsterhunter.dm
@@ -8,6 +8,7 @@
roundend_category = "Monster Hunters"
antagpanel_category = "Monster Hunter"
job_rank = ROLE_MONSTERHUNTER
+ preview_outfit = /datum/outfit/monsterhunter
var/list/datum/action/powers = list()
var/datum/martial_art/hunterfu/my_kungfu = new
var/give_objectives = TRUE
@@ -154,4 +155,13 @@
/datum/status_effect/agent_pinpointer/hunter_edition/Destroy()
if(scan_target)
to_chat(owner, span_notice("You've lost the trail."))
- . = ..()
\ No newline at end of file
+ . = ..()
+
+/datum/outfit/monsterhunter
+ name = "Monster Hunter"
+
+ head = /obj/item/clothing/head/helmet/chaplain/witchunter_hat
+ uniform = /obj/item/clothing/under/rank/chaplain
+ suit = /obj/item/clothing/suit/armor/riot/chaplain/witchhunter
+ l_hand = /obj/item/stake
+ r_hand = /obj/item/stake/hardened/silver
diff --git a/code/modules/antagonists/nightmare/nightmare.dm b/code/modules/antagonists/nightmare/nightmare.dm
index dc950850c189..99ea5c6d97d9 100644
--- a/code/modules/antagonists/nightmare/nightmare.dm
+++ b/code/modules/antagonists/nightmare/nightmare.dm
@@ -3,6 +3,7 @@
show_in_antagpanel = FALSE
show_name_in_check_antagonists = TRUE
show_to_ghosts = TRUE
+ job_rank = ROLE_NIGHTMARE
/datum/antagonist/nightmare/proc/forge_objectives()
var/datum/objective/new_objective = new
@@ -21,3 +22,11 @@
/datum/antagonist/nightmare/greet()
owner.announce_objectives()
SEND_SOUND(owner.current, sound('sound/magic/ethereal_exit.ogg'))
+
+/datum/antagonist/nightmare/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/nightmaredummy = new
+ nightmaredummy.set_species(/datum/species/shadow/nightmare)
+ var/icon/nightmare_icon = render_preview_outfit(null, nightmaredummy)
+ qdel(nightmaredummy)
+
+ return finish_preview_icon(nightmare_icon)
diff --git a/code/modules/antagonists/ninja/ninja.dm b/code/modules/antagonists/ninja/ninja.dm
index 460a396e0f39..31b35cd0c7df 100644
--- a/code/modules/antagonists/ninja/ninja.dm
+++ b/code/modules/antagonists/ninja/ninja.dm
@@ -10,6 +10,7 @@ GLOBAL_LIST_EMPTY(ninja_capture)
var/helping_station = FALSE
var/give_objectives = TRUE
var/give_equipment = TRUE
+ preview_outfit = /datum/outfit/ninja
/datum/antagonist/ninja/New()
if(helping_station)
diff --git a/code/modules/antagonists/nukeop/clownop.dm b/code/modules/antagonists/nukeop/clownop.dm
index fe2addd8dfaa..79b2593a243a 100644
--- a/code/modules/antagonists/nukeop/clownop.dm
+++ b/code/modules/antagonists/nukeop/clownop.dm
@@ -3,7 +3,9 @@
name = "Clown Operative"
roundend_category = "clown operatives"
antagpanel_category = "ClownOp"
+ job_rank = ROLE_CLOWNOP
nukeop_outfit = /datum/outfit/syndicate/clownop
+ preview_outfit = /datum/outfit/syndicate/clownop
/datum/antagonist/nukeop/clownop/greet()
owner.current.playsound_local(get_turf(owner.current), 'sound/ambience/antag/hornin.ogg', 100, FALSE, pressure_affected = FALSE)
diff --git a/code/modules/antagonists/nukeop/nukeop.dm b/code/modules/antagonists/nukeop/nukeop.dm
index 6eb58d1f04cf..2230cf6a5c50 100644
--- a/code/modules/antagonists/nukeop/nukeop.dm
+++ b/code/modules/antagonists/nukeop/nukeop.dm
@@ -11,6 +11,11 @@
var/nukeop_outfit = /datum/outfit/syndicate
can_hijack = HIJACK_HIJACKER //Alternative way to wipe out the station.
+ preview_outfit = /datum/outfit/nuclear_operative_elite
+
+ /// In the preview icon, the nukies who are behind the leader
+ var/preview_outfit_behind = /datum/outfit/nuclear_operative
+
/datum/antagonist/nukeop/proc/update_synd_icons_added(mob/living/M)
var/datum/atom_hud/antag/opshud = GLOB.huds[ANTAG_HUD_OPS]
opshud.join_hud(M)
@@ -157,11 +162,48 @@
else
to_chat(admin, span_danger("No valid nuke found!"))
+/datum/antagonist/nukeop/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/captain = new
+ var/icon/final_icon = render_preview_outfit(preview_outfit, captain)
+ final_icon.Blend(make_assistant_icon(), ICON_UNDERLAY, -8, 0)
+ final_icon.Blend(make_assistant_icon(), ICON_UNDERLAY, 8, 0)
+
+ return finish_preview_icon(final_icon)
+
+/datum/antagonist/nukeop/proc/make_assistant_icon()
+ var/mob/living/carbon/human/dummy/assistant = new
+ var/icon/assistant_icon = render_preview_outfit(preview_outfit_behind, assistant)
+ assistant_icon.ChangeOpacity(0.5)
+
+ return assistant_icon
+
+/datum/outfit/nuclear_operative
+ name = "Nuclear Operative (Preview only)"
+ mask = /obj/item/clothing/mask/gas/syndicate
+ uniform = /obj/item/clothing/under/syndicate
+ suit = /obj/item/clothing/suit/space/hardsuit/syndi
+ head = /obj/item/clothing/head/helmet/space/hardsuit/syndi
+
+/datum/outfit/nuclear_operative_elite
+ name = "Nuclear Operative (Elite, Preview only)"
+ mask = /obj/item/clothing/mask/gas/syndicate
+ uniform = /obj/item/clothing/under/syndicate
+ suit = /obj/item/clothing/suit/space/hardsuit/syndi/elite
+ head = /obj/item/clothing/head/helmet/space/hardsuit/syndi/elite
+ r_hand = /obj/item/shield/energy
+
+/datum/outfit/nuclear_operative_elite/post_equip(mob/living/carbon/human/H, visualsOnly)
+ var/obj/item/shield/energy/shield = locate() in H.held_items
+ shield.icon_state = "[shield.base_icon_state]1"
+ H.update_inv_hands()
+
/datum/antagonist/nukeop/leader
name = "Nuclear Operative Leader"
nukeop_outfit = /datum/outfit/syndicate/leader
always_new_team = TRUE
var/title
+ preview_outfit = /datum/outfit/nuclear_operative
+ preview_outfit_behind = null
/datum/antagonist/nukeop/leader/memorize_code()
..()
diff --git a/code/modules/antagonists/pirate/pirate.dm b/code/modules/antagonists/pirate/pirate.dm
index 5e8e3d8d7b5c..2aeedf60d6fa 100644
--- a/code/modules/antagonists/pirate/pirate.dm
+++ b/code/modules/antagonists/pirate/pirate.dm
@@ -1,11 +1,14 @@
/datum/antagonist/pirate
name = "Space Pirate"
- job_rank = ROLE_TRAITOR
+ job_rank = ROLE_PIRATE
roundend_category = "space pirates"
antagpanel_category = "Pirate"
show_to_ghosts = TRUE
var/datum/team/pirate/crew
+ /// In the preview icon, the nukies who are behind the leader
+ var/preview_outfit_behind = /datum/outfit/pirate/space/gunner
+
/datum/antagonist/pirate/greet()
to_chat(owner, span_boldannounce("You are a Space Pirate!"))
to_chat(owner, "
The station refused to pay for your protection, protect the ship, siphon the credits from the station and raid it for even more loot. [span_notice(" As a pirate, you are NOT authorized to murder the station's inhabitants without good reason.")]")
@@ -108,3 +111,70 @@
parts += "
The pirate crew has failed. "
return "
[parts.Join(" ")]
"
+
+/datum/antagonist/pirate/get_preview_icon()
+ if (!preview_outfit)
+ return null
+
+ var/icon/final_icon = render_preview_outfit(preview_outfit)
+
+ if (!isnull(preview_outfit_behind))
+ var/icon/teammate = render_preview_outfit(preview_outfit_behind)
+ teammate.Blend(rgb(128, 128, 128, 128), ICON_MULTIPLY)
+
+ final_icon.Blend(teammate, ICON_OVERLAY, -world.icon_size / 4, 0)
+ final_icon.Blend(teammate, ICON_OVERLAY, world.icon_size / 4, 0)
+
+ return finish_preview_icon(final_icon)
+
+/*
+/datum/antagonist/pirate/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/zombiedummy = new
+
+ zombiedummy.set_species(/datum/species/zombie)
+
+ var/icon/zombie_icon = render_preview_outfit(null, zombiedummy)
+
+ qdel(zombiedummy)
+
+ return finish_preview_icon(zombie_icon)
+
+
+/datum/antagonist/pirate/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/captain = new
+ var/mob/living/carbon/human/dummy/consistent/gunner = new
+
+ captain.set_species(/datum/species/skeleton)
+ gunner.set_species(/datum/species/skeleton)
+
+ var/icon/final_icon = render_preview_outfit(/datum/outfit/pirate/space/captain, captain)
+ var/icon/teammate = render_preview_outfit(/datum/outfit/pirate/space/gunner, gunner)
+ //teammate.Blend(rgb(128, 128, 128, 128), ICON_MULTIPLY)
+
+ final_icon.Blend(teammate, ICON_OVERLAY, -world.icon_size / 4, 0)
+ final_icon.Blend(teammate, ICON_OVERLAY, world.icon_size / 4, 0)
+
+ qdel(teammate)
+ qdel(captain)
+
+ return finish_preview_icon(final_icon)
+
+*/
+/datum/antagonist/pirate/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/captain = new
+ captain.set_species(/datum/species/skeleton)
+
+ var/icon/final_icon = render_preview_outfit(/datum/outfit/pirate/space/captain, captain)
+ final_icon.Blend(make_assistant_icon(), ICON_UNDERLAY, -8, 0)
+ final_icon.Blend(make_assistant_icon(), ICON_UNDERLAY, 8, 0)
+
+ return finish_preview_icon(final_icon)
+
+/datum/antagonist/pirate/proc/make_assistant_icon()
+ var/mob/living/carbon/human/dummy/assistant = new
+ assistant.set_species(/datum/species/skeleton)
+ var/icon/assistant_icon = render_preview_outfit(/datum/outfit/pirate/space/gunner, assistant)
+ assistant_icon.ChangeOpacity(0.5)
+
+ qdel(assistant)
+ return assistant_icon
diff --git a/code/modules/antagonists/revenant/revenant_antag.dm b/code/modules/antagonists/revenant/revenant_antag.dm
index 6eacc1697bec..d0e62e385a4c 100644
--- a/code/modules/antagonists/revenant/revenant_antag.dm
+++ b/code/modules/antagonists/revenant/revenant_antag.dm
@@ -19,3 +19,6 @@
/datum/antagonist/revenant/on_gain()
forge_objectives()
. = ..()
+
+/datum/antagonist/revenant/get_preview_icon()
+ return finish_preview_icon(icon('icons/mob/mob.dmi', "revenant_idle"))
diff --git a/code/modules/antagonists/revolution/revolution.dm b/code/modules/antagonists/revolution/revolution.dm
index 6c3702f67d39..3df49df63945 100644
--- a/code/modules/antagonists/revolution/revolution.dm
+++ b/code/modules/antagonists/revolution/revolution.dm
@@ -163,6 +163,7 @@
var/remove_clumsy = FALSE
var/give_flash = TRUE
var/give_hud = TRUE
+ preview_outfit = /datum/outfit/revolutionary
/datum/antagonist/rev/head/antag_listing_name()
return ..() + "(Leader)"
@@ -172,6 +173,37 @@
equip_head()
return ..()
+/datum/antagonist/rev/head/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/antagonist/rev/head/proc/make_assistant_icon(hairstyle)
+ var/mob/living/carbon/human/dummy/consistent/assistant = new
+ assistant.hair_style = hairstyle
+ 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/antagonist/rev/head/proc/equip_head()
var/obj/item/book/granter/crafting_recipe/weapons/W = new
W.on_reading_finished(owner.current)
@@ -439,3 +471,12 @@
/datum/team/revolution/is_gamemode_hero()
return SSticker.mode.name == "revolution"
+
+/datum/outfit/revolutionary
+ name = "Revolutionary (Preview only)"
+
+ uniform = /obj/item/clothing/under/yogs/soviet_dress_uniform
+ head = /obj/item/clothing/head/ushanka
+ gloves = /obj/item/clothing/gloves/color/black
+ l_hand = /obj/item/twohanded/spear
+ r_hand = /obj/item/assembly/flash
diff --git a/code/modules/antagonists/sentient_creature.dm b/code/modules/antagonists/sentient_creature.dm
new file mode 100644
index 000000000000..540533dfdea5
--- /dev/null
+++ b/code/modules/antagonists/sentient_creature.dm
@@ -0,0 +1,18 @@
+/datum/antagonist/sentient_creature
+ name = "\improper Sentient Creature"
+ show_in_antagpanel = FALSE
+ show_in_roundend = FALSE
+
+/datum/antagonist/sentient_creature/get_preview_icon()
+ var/icon/final_icon = icon('icons/mob/pets.dmi', "corgi")
+
+ var/icon/broodmother = icon('icons/mob/lavaland/lavaland_elites.dmi', "broodmother")
+ broodmother.Blend(rgb(128, 128, 128, 128), ICON_MULTIPLY)
+ final_icon.Blend(broodmother, ICON_UNDERLAY, -world.icon_size / 4, 0)
+
+ var/icon/rat = icon('icons/mob/animal.dmi', "regalrat")
+ rat.Blend(rgb(128, 128, 128, 128), ICON_MULTIPLY)
+ final_icon.Blend(rat, ICON_UNDERLAY, world.icon_size / 4, 0)
+
+ final_icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+ return final_icon
diff --git a/code/modules/antagonists/space_dragon/space_dragon.dm b/code/modules/antagonists/space_dragon/space_dragon.dm
index 502067004df8..0dfce5265a93 100644
--- a/code/modules/antagonists/space_dragon/space_dragon.dm
+++ b/code/modules/antagonists/space_dragon/space_dragon.dm
@@ -25,6 +25,19 @@
forge_objectives()
. = ..()
+/datum/antagonist/space_dragon/get_preview_icon()
+ var/icon/icon = icon('icons/mob/spacedragon.dmi', "spacedragon")
+
+ icon.Blend(COLOR_STRONG_VIOLET, 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)
+ icon.Shift(EAST, 8)
+ icon.Shift(SOUTH, 6)
+
+ return icon
+
/datum/objective/summon_carp
var/datum/antagonist/space_dragon/dragon
explanation_text = "Summon and protect the rifts to flood the station with carp."
@@ -48,4 +61,4 @@
parts += "
The [name] has failed! "
parts += ""
parts += printplayerlist(carp)
- return "
[parts.Join(" ")]
"
\ No newline at end of file
+ return "
[parts.Join(" ")]
"
diff --git a/code/modules/antagonists/swarmer/swarmer.dm b/code/modules/antagonists/swarmer/swarmer.dm
index caca1db27c7a..14beba879fa4 100644
--- a/code/modules/antagonists/swarmer/swarmer.dm
+++ b/code/modules/antagonists/swarmer/swarmer.dm
@@ -37,3 +37,8 @@
..()
if(!mind.has_antag_datum(/datum/antagonist/swarmer))
mind.add_antag_datum(/datum/antagonist/swarmer)
+
+/datum/antagonist/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)
diff --git a/code/modules/antagonists/traitor/IAA/internal_affairs.dm b/code/modules/antagonists/traitor/IAA/internal_affairs.dm
index e9f3c6ba5108..7cb75a042677 100644
--- a/code/modules/antagonists/traitor/IAA/internal_affairs.dm
+++ b/code/modules/antagonists/traitor/IAA/internal_affairs.dm
@@ -13,6 +13,7 @@
var/last_man_standing = FALSE
var/list/datum/mind/targets_stolen
greentext_achieve = /datum/achievement/greentext/internal
+ preview_outfit = /datum/outfit/assassin
/datum/antagonist/traitor/internal_affairs/proc/give_pinpointer()
if(owner && owner.current)
diff --git a/code/modules/antagonists/traitor/datum_traitor.dm b/code/modules/antagonists/traitor/datum_traitor.dm
index fa5532a83750..3719b40c6ddb 100644
--- a/code/modules/antagonists/traitor/datum_traitor.dm
+++ b/code/modules/antagonists/traitor/datum_traitor.dm
@@ -7,6 +7,7 @@
antagpanel_category = "Traitor"
job_rank = ROLE_TRAITOR
antag_moodlet = /datum/mood_event/focused
+ preview_outfit = /datum/outfit/traitor
var/special_role = ROLE_TRAITOR
var/employer = "The Syndicate"
var/give_objectives = TRUE
@@ -452,6 +453,19 @@
return message
-
/datum/antagonist/traitor/is_gamemode_hero()
return SSticker.mode.name == "traitor"
+
+/datum/outfit/traitor
+ name = "Traitor (Preview only)"
+ uniform = /obj/item/clothing/under/color/grey
+ suit = /obj/item/clothing/suit/armor/laserproof
+ gloves = /obj/item/clothing/gloves/color/yellow
+ mask = /obj/item/clothing/mask/gas
+ l_hand = /obj/item/melee/transforming/energy/sword
+ r_hand = /obj/item/gun/energy/kinetic_accelerator/crossbow
+ head = /obj/item/clothing/head/helmet
+
+/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.transform_weapon(H)
diff --git a/code/modules/antagonists/wizard/wizard.dm b/code/modules/antagonists/wizard/wizard.dm
index 0a347c49397a..1ec39021677c 100644
--- a/code/modules/antagonists/wizard/wizard.dm
+++ b/code/modules/antagonists/wizard/wizard.dm
@@ -14,6 +14,7 @@
var/wiz_age = WIZARD_AGE_MIN /* Wizards by nature cannot be too young. */
can_hijack = HIJACK_HIJACKER
show_to_ghosts = TRUE
+ preview_outfit = /datum/outfit/wizard
/datum/antagonist/wizard/on_gain()
register()
diff --git a/code/modules/antagonists/xeno/xeno.dm b/code/modules/antagonists/xeno/xeno.dm
index a28f3751683b..d41713dbb4f1 100644
--- a/code/modules/antagonists/xeno/xeno.dm
+++ b/code/modules/antagonists/xeno/xeno.dm
@@ -36,4 +36,7 @@
/mob/living/carbon/alien/mind_initialize()
..()
if(!mind.has_antag_datum(/datum/antagonist/xeno))
- mind.add_antag_datum(/datum/antagonist/xeno)
\ No newline at end of file
+ mind.add_antag_datum(/datum/antagonist/xeno)
+
+/datum/antagonist/xeno/get_preview_icon()
+ return finish_preview_icon(icon('icons/mob/alien.dmi', "alienh"))
diff --git a/code/modules/antagonists/zombie/zombie.dm b/code/modules/antagonists/zombie/zombie.dm
index 4658872e6b38..beba12770422 100644
--- a/code/modules/antagonists/zombie/zombie.dm
+++ b/code/modules/antagonists/zombie/zombie.dm
@@ -430,3 +430,14 @@
ready = FALSE
#undef TIER_2_TIME
+
+/datum/antagonist/zombie/get_preview_icon()
+ var/mob/living/carbon/human/dummy/consistent/zombiedummy = new
+
+ zombiedummy.set_species(/datum/species/zombie)
+
+ var/icon/zombie_icon = render_preview_outfit(null, zombiedummy)
+
+ qdel(zombiedummy)
+
+ return finish_preview_icon(zombie_icon)
diff --git a/code/modules/asset_cache/asset_list.dm b/code/modules/asset_cache/asset_list.dm
index 221febbe14d4..b9b8f872ca32 100644
--- a/code/modules/asset_cache/asset_list.dm
+++ b/code/modules/asset_cache/asset_list.dm
@@ -1,3 +1,4 @@
+#define ASSET_CROSS_ROUND_CACHE_DIRECTORY "tmp/assets"
//These datums are used to populate the asset cache, the proc "register()" does this.
//Place any asset datums you create in asset_list_items.dm
@@ -6,25 +7,60 @@
GLOBAL_LIST_EMPTY(asset_datums)
//get an assetdatum or make a new one
-/proc/get_asset_datum(type)
+//does NOT ensure it's filled, if you want that use get_asset_datum()
+/proc/load_asset_datum(type)
return GLOB.asset_datums[type] || new type()
+/proc/get_asset_datum(type)
+ var/datum/asset/loaded_asset = GLOB.asset_datums[type] || new type()
+ return loaded_asset.ensure_ready()
+
/datum/asset
var/_abstract = /datum/asset
+ var/cached_serialized_url_mappings
+ var/cached_serialized_url_mappings_transport_type
+
+ /// Whether or not this asset should be loaded in the "early assets" SS
+ var/early = FALSE
+
+ /// Whether or not this asset can be cached across rounds of the same commit under the `CACHE_ASSETS` config.
+ /// This is not a *guarantee* the asset will be cached. Not all asset subtypes respect this field, and the
+ /// config can, of course, be disabled.
+ var/cross_round_cachable = FALSE
/datum/asset/New()
GLOB.asset_datums[type] = src
register()
+/// Stub that allows us to react to something trying to get us
+/// Not useful here, more handy for sprite sheets
+/datum/asset/proc/ensure_ready()
+ return src
+
+/// Stub to hook into if your asset is having its generation queued by SSasset_loading
+/datum/asset/proc/queued_generation()
+ CRASH("[type] inserted into SSasset_loading despite not implementing /proc/queued_generation")
+
/datum/asset/proc/get_url_mappings()
return list()
+/// Returns a cached tgui message of URL mappings
+/datum/asset/proc/get_serialized_url_mappings()
+ if (isnull(cached_serialized_url_mappings) || cached_serialized_url_mappings_transport_type != SSassets.transport.type)
+ cached_serialized_url_mappings = TGUI_CREATE_MESSAGE("asset/mappings", get_url_mappings())
+ cached_serialized_url_mappings_transport_type = SSassets.transport.type
+
+ return cached_serialized_url_mappings
+
/datum/asset/proc/register()
return
/datum/asset/proc/send(client)
return
+/// Returns whether or not the asset should attempt to read from cache
+/datum/asset/proc/should_refresh()
+ return !cross_round_cachable || !CONFIG_GET(flag/cache_assets)
/// If you don't need anything complicated.
/datum/asset/simple
@@ -67,7 +103,7 @@ GLOBAL_LIST_EMPTY(asset_datums)
/datum/asset/group/register()
for(var/type in children)
- get_asset_datum(type)
+ load_asset_datum(type)
/datum/asset/group/send(client/C)
for(var/type in children)
@@ -92,12 +128,68 @@ GLOBAL_LIST_EMPTY(asset_datums)
/datum/asset/spritesheet
_abstract = /datum/asset/spritesheet
var/name
+ /// List of arguments to pass into queuedInsert
+ /// Exists so we can queue icon insertion, mostly for stuff like preferences
+ var/list/to_generate = list()
var/list/sizes = list() // "32x32" -> list(10, icon/normal, icon/stripped)
var/list/sprites = list() // "foo_bar" -> list("32x32", 5)
+ var/list/cached_spritesheets_needed
+ var/generating_cache = FALSE
+ var/fully_generated = FALSE
+ /// If this asset should be fully loaded on new
+ /// Defaults to false so we can process this stuff nicely
+ var/load_immediately = FALSE
+
+/datum/asset/spritesheet/proc/should_load_immediately()
+#ifdef DO_NOT_DEFER_ASSETS
+ return TRUE
+#else
+ return load_immediately
+#endif
+
+/datum/asset/spritesheet/should_refresh()
+ if (..())
+ return TRUE
+
+ // Static so that the result is the same, even when the files are created, for this run
+ var/static/should_refresh = null
+
+ if (isnull(should_refresh))
+ // `fexists` seems to always fail on static-time
+ should_refresh = !fexists("[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[name].css")
+
+ return should_refresh
/datum/asset/spritesheet/register()
+ SHOULD_NOT_OVERRIDE(TRUE)
+
if (!name)
CRASH("spritesheet [type] cannot register without a name")
+
+ if (!should_refresh() && read_from_cache())
+ fully_generated = TRUE
+ return
+
+ // If it's cached, may as well load it now, while the loading is cheap
+ if(CONFIG_GET(flag/cache_assets) && cross_round_cachable)
+ load_immediately = TRUE
+
+ create_spritesheets()
+ if(should_load_immediately())
+ realize_spritesheets(yield = FALSE)
+ else
+ SSasset_loading.queue_asset(src)
+
+/datum/asset/spritesheet/proc/realize_spritesheets(yield)
+ if(fully_generated)
+ return
+ while(length(to_generate))
+ var/list/stored_args = to_generate[to_generate.len]
+ to_generate.len--
+ queuedInsert(arglist(stored_args))
+ if(yield && TICK_CHECK)
+ return
+
ensure_stripped()
for(var/size_id in sizes)
var/size = sizes[size_id]
@@ -109,17 +201,39 @@ GLOBAL_LIST_EMPTY(asset_datums)
SSassets.transport.register_asset(res_name, fcopy_rsc(fname))
fdel(fname)
-/datum/asset/spritesheet/send(client/C)
+ if (CONFIG_GET(flag/cache_assets) && cross_round_cachable)
+ write_to_cache()
+ fully_generated = TRUE
+ // If we were ever in there, remove ourselves
+ SSasset_loading.dequeue_asset(src)
+
+/datum/asset/spritesheet/queued_generation()
+ realize_spritesheets(yield = TRUE)
+
+/datum/asset/spritesheet/ensure_ready()
+ if(!fully_generated)
+ realize_spritesheets(yield = FALSE)
+ return ..()
+
+/datum/asset/spritesheet/send(client/client)
if (!name)
return
+
+ if (!should_refresh())
+ return send_from_cache(client)
+
var/all = list("spritesheet_[name].css")
for(var/size_id in sizes)
all += "[name]_[size_id].png"
- . = SSassets.transport.send_assets(C, all)
+ . = SSassets.transport.send_assets(client, all)
/datum/asset/spritesheet/get_url_mappings()
if (!name)
return
+
+ if (!should_refresh())
+ return get_cached_url_mappings()
+
. = list("spritesheet_[name].css" = SSassets.transport.get_asset_url("spritesheet_[name].css"))
for(var/size_id in sizes)
.["[name]_[size_id].png"] = SSassets.transport.get_asset_url("[name]_[size_id].png")
@@ -147,7 +261,7 @@ GLOBAL_LIST_EMPTY(asset_datums)
for (var/size_id in sizes)
var/size = sizes[size_id]
var/icon/tiny = size[SPRSZ_ICON]
- out += ".[name][size_id]{display:inline-block;width:[tiny.Width()]px;height:[tiny.Height()]px;background:url('[SSassets.transport.get_asset_url("[name]_[size_id].png")]') no-repeat;}"
+ out += ".[name][size_id]{display:inline-block;width:[tiny.Width()]px;height:[tiny.Height()]px;background:url('[get_background_url("[name]_[size_id].png")]') no-repeat;}"
for (var/sprite_id in sprites)
var/sprite = sprites[sprite_id]
@@ -165,7 +279,75 @@ GLOBAL_LIST_EMPTY(asset_datums)
return out.Join("\n")
+/datum/asset/spritesheet/proc/read_from_cache()
+ var/replaced_css = file2text("[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[name].css")
+
+ var/regex/find_background_urls = regex(@"background:url\('%(.+?)%'\)", "g")
+ while (find_background_urls.Find(replaced_css))
+ var/asset_id = find_background_urls.group[1]
+ var/asset_cache_item = SSassets.transport.register_asset(asset_id, "[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[asset_id]")
+ var/asset_url = SSassets.transport.get_asset_url(asset_cache_item = asset_cache_item)
+ replaced_css = replacetext(replaced_css, find_background_urls.match, "background:url('[asset_url]')")
+ LAZYADD(cached_spritesheets_needed, asset_id)
+
+ var/replaced_css_filename = "data/spritesheets/spritesheet_[name].css"
+ rustg_file_write(replaced_css, replaced_css_filename)
+ SSassets.transport.register_asset("spritesheet_[name].css", replaced_css_filename)
+
+ fdel(replaced_css_filename)
+
+ return TRUE
+
+/datum/asset/spritesheet/proc/send_from_cache(client/client)
+ if (isnull(cached_spritesheets_needed))
+ stack_trace("cached_spritesheets_needed was null when sending assets from [type] from cache")
+ cached_spritesheets_needed = list()
+
+ return SSassets.transport.send_assets(client, cached_spritesheets_needed + "spritesheet_[name].css")
+
+/// Returns the URL to put in the background:url of the CSS asset
+/datum/asset/spritesheet/proc/get_background_url(asset)
+ if (generating_cache)
+ return "%[asset]%"
+ else
+ return SSassets.transport.get_asset_url(asset)
+
+/datum/asset/spritesheet/proc/write_to_cache()
+ for (var/size_id in sizes)
+ fcopy(SSassets.cache["[name]_[size_id].png"].resource, "[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[name]_[size_id].png")
+
+ generating_cache = TRUE
+ var/mock_css = generate_css()
+ generating_cache = FALSE
+
+ rustg_file_write(mock_css, "[ASSET_CROSS_ROUND_CACHE_DIRECTORY]/spritesheet.[name].css")
+
+/datum/asset/spritesheet/proc/get_cached_url_mappings()
+ var/list/mappings = list()
+ mappings["spritesheet_[name].css"] = SSassets.transport.get_asset_url("spritesheet_[name].css")
+
+ for (var/asset_name in cached_spritesheets_needed)
+ mappings[asset_name] = SSassets.transport.get_asset_url(asset_name)
+
+ return mappings
+
+/// Override this in order to start the creation of the spritehseet.
+/// This is where all your Insert, InsertAll, etc calls should be inside.
+/datum/asset/spritesheet/proc/create_spritesheets()
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("create_spritesheets() not implemented for [type]!")
+
/datum/asset/spritesheet/proc/Insert(sprite_name, icon/I, icon_state="", dir=SOUTH, frame=1, moving=FALSE)
+ if(should_load_immediately())
+ queuedInsert(sprite_name, I, icon_state, dir, frame, moving)
+ else
+ to_generate += list(args.Copy())
+
+// LEMON NOTE
+// A GOON CODER SAYS BAD ICON ERRORS CAN BE THROWN BY THE "ICON CACHE"
+// APPARENTLY IT MAKES ICONS IMMUTABLE
+// LOOK INTO USING THE MUTABLE APPEARANCE PATTERN HERE
+/datum/asset/spritesheet/proc/queuedInsert(sprite_name, icon/I, icon_state="", dir=SOUTH, frame=1, moving=FALSE)
I = icon(I, icon_state=icon_state, dir=dir, frame=frame, moving=moving)
if (!I || !length(icon_states(I))) // that direction or state doesn't exist
return
@@ -178,8 +360,10 @@ GLOBAL_LIST_EMPTY(asset_datums)
if (size)
var/position = size[SPRSZ_COUNT]++
var/icon/sheet = size[SPRSZ_ICON]
+ var/icon/sheet_copy = icon(sheet)
size[SPRSZ_STRIPPED] = null
- sheet.Insert(I, icon_state=sprite_name)
+ sheet_copy.Insert(I, icon_state=sprite_name)
+ size[SPRSZ_ICON] = sheet_copy
sprites[sprite_name] = list(size_id, position)
else
sizes[size_id] = size = list(1, I, null)
@@ -228,10 +412,9 @@ GLOBAL_LIST_EMPTY(asset_datums)
_abstract = /datum/asset/spritesheet/simple
var/list/assets
-/datum/asset/spritesheet/simple/register()
+/datum/asset/spritesheet/simple/create_spritesheets()
for (var/key in assets)
Insert(key, assets[key])
- ..()
//Generates assets based on iconstates of a single icon
/datum/asset/simple/icon_states
@@ -315,3 +498,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, "data/[name].json")
+
+/datum/asset/json/get_url_mappings()
+ return list(
+ "[name].json" = SSassets.transport.get_asset_url("data/[name].json"),
+ )
+
+/datum/asset/json/register()
+ var/filename = "data/[name].json"
+ fdel(filename)
+ text2file(json_encode(generate()), filename)
+ SSassets.transport.register_asset(filename, 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 c2c219e895f0..0d41cd60127f 100644
--- a/code/modules/asset_cache/asset_list_items.dm
+++ b/code/modules/asset_cache/asset_list_items.dm
@@ -161,7 +161,7 @@
"fa-regular-400.ttf" = 'html/font-awesome/webfonts/fa-regular-400.ttf',
"fa-solid-900.ttf" = 'html/font-awesome/webfonts/fa-solid-900.ttf',
"fa-v4compatibility.ttf" = 'html/font-awesome/webfonts/fa-v4compatibility.ttf',
- "v4shim.css" = 'html/font-awesome/css/v4-shims.min.css'
+ "v4shim.css" = 'html/font-awesome/css/v4-shims.min.css',
)
parents = list("font-awesome.css" = 'html/font-awesome/css/all.min.css')
@@ -177,7 +177,7 @@
/datum/asset/spritesheet/chat
name = "chat"
-/datum/asset/spritesheet/chat/register()
+/datum/asset/spritesheet/chat/create_spritesheets()
InsertAll("emoji", 'icons/emoji.dmi')
// pre-loading all lanugage icons also helps to avoid meta
InsertAll("language", 'icons/misc/language.dmi')
@@ -188,7 +188,6 @@
if (icon != 'icons/misc/language.dmi')
var/icon_state = initial(L.icon_state)
Insert("language-[icon_state]", icon, icon_state=icon_state)
- ..()
/datum/asset/simple/lobby
assets = list(
@@ -288,10 +287,9 @@
/datum/asset/spritesheet/pipes
name = "pipes"
-/datum/asset/spritesheet/pipes/register()
+/datum/asset/spritesheet/pipes/create_spritesheets()
for (var/each in list('icons/obj/atmospherics/pipes/pipe_item.dmi', 'icons/obj/atmospherics/pipes/disposal.dmi', 'icons/obj/atmospherics/pipes/transit_tube.dmi', 'icons/obj/plumbing/fluid_ducts.dmi'))
InsertAll("", each, GLOB.alldirs)
- ..()
/datum/asset/simple/security_armaments
assets = list(
@@ -303,7 +301,7 @@
/datum/asset/spritesheet/research_designs
name = "design"
-/datum/asset/spritesheet/research_designs/register()
+/datum/asset/spritesheet/research_designs/create_spritesheets()
for (var/path in subtypesof(/datum/design))
var/datum/design/D = path
@@ -314,9 +312,11 @@
if(initial(D.research_icon) && initial(D.research_icon_state)) //If the design has an icon replacement skip the rest
icon_file = initial(D.research_icon)
icon_state = initial(D.research_icon_state)
+ #ifdef UNIT_TESTS
if(!(icon_state in icon_states(icon_file)))
- warning("design [D] with icon '[icon_file]' missing state '[icon_state]'")
+ stack_trace("design [D] with icon '[icon_file]' missing state '[icon_state]'")
continue
+ #endif
I = icon(icon_file, icon_state, SOUTH)
else
@@ -338,10 +338,11 @@
icon_file = initial(item.icon)
icon_state = initial(item.icon_state)
-
+ #ifdef UNIT_TESTS
if(!(icon_state in icon_states(icon_file)))
- warning("design [D] with icon '[icon_file]' missing state '[icon_state]'")
+ stack_trace("design [D] with icon '[icon_file]' missing state '[icon_state]'")
continue
+ #endif
I = icon(icon_file, icon_state, SOUTH)
// computers (and snowflakes) get their screen and keyboard sprites
@@ -356,12 +357,11 @@
I.Blend(icon(icon_file, keyboard, SOUTH), ICON_OVERLAY)
Insert(initial(D.id), I)
- return ..()
/datum/asset/spritesheet/vending
name = "vending"
-/datum/asset/spritesheet/vending/register()
+/datum/asset/spritesheet/vending/create_spritesheets()
for (var/k in GLOB.vending_products)
var/atom/item = k
if (!ispath(item, /atom))
@@ -373,33 +373,35 @@
var/obj/item/ammo_box/ammoitem = item
if(initial(ammoitem.multiple_sprites))
icon_state = "[icon_state]-[initial(ammoitem.max_ammo)]"
- var/icon/I
+ #ifdef UNIT_TESTS
var/icon_states_list = icon_states(icon_file)
- if(icon_state in icon_states_list)
- I = icon(icon_file, icon_state, SOUTH)
- var/c = initial(item.color)
- if (!isnull(c) && c != "#FFFFFF")
- I.Blend(c, ICON_MULTIPLY)
- else
+ 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]")
- I = icon('icons/turf/floors.dmi', "", SOUTH)
+ continue
+ #endif
+
+ var/icon/I = icon(icon_file, icon_state, SOUTH)
+ var/c = initial(item.color)
+ if (!isnull(c) && c != "#FFFFFF")
+ I.Blend(c, ICON_MULTIPLY)
var/imgid = replacetext(replacetext("[item]", "/obj/item/", ""), "/", "-")
Insert(imgid, I)
- return ..()
/datum/asset/spritesheet/uplink
name = "uplink"
+ load_immediately = TRUE // needed to prevent duplicates
-/datum/asset/spritesheet/uplink/register()
+/datum/asset/spritesheet/uplink/create_spritesheets()
for(var/path in GLOB.uplink_items)
var/datum/uplink_item/U = path
if (!ispath(U, /datum/uplink_item))
@@ -415,15 +417,10 @@
var/obj/item/ammo_box/ammoitem = item
if(initial(ammoitem.multiple_sprites))
icon_state = "[icon_state]-[initial(ammoitem.max_ammo)]"
- var/icon/I
+ #ifdef UNIT_TESTS
var/icon_states_list = icon_states(icon_file)
- if(icon_state in icon_states_list)
- I = icon(icon_file, icon_state, SOUTH)
- var/c = initial(item.color)
- if (!isnull(c) && c != "#FFFFFF")
- I.Blend(c, ICON_MULTIPLY)
- else
+ if (!(icon_state in icon_states_list))
var/icon_states_string
for (var/an_icon_state in icon_states_list)
if (!icon_states_string)
@@ -431,13 +428,18 @@
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]")
- I = icon('icons/turf/floors.dmi', "", SOUTH)
+ continue
+ #endif
- var/imgid = replacetext(replacetext("[item]", "/obj/item/", ""), "/", "-")
+ var/icon/I = icon(icon_file, icon_state, SOUTH)
+ var/c = initial(item.color)
+ if (!isnull(c) && c != "#FFFFFF")
+ I.Blend(c, ICON_MULTIPLY)
+ var/imgid = replacetext(replacetext("[item]", "/obj/item/", ""), "/", "-")
+
if(!sprites[imgid])
Insert(imgid, I)
- return ..()
/datum/asset/simple/genetics
assets = list(
@@ -459,14 +461,13 @@
/datum/asset/spritesheet/sheetmaterials
name = "sheetmaterials"
-/datum/asset/spritesheet/sheetmaterials/register()
+/datum/asset/spritesheet/sheetmaterials/create_spritesheets()
InsertAll("", 'icons/obj/stack_objects.dmi')
// Special case to handle Bluespace Crystals
Insert("polycrystal", 'icons/obj/telescience.dmi', "polycrystal")
Insert("dilithium_polycrystal", 'yogstation/icons/obj/telescience.dmi', "dilithium_polycrystal") //yogs: same as above but for dilithium
- ..()
/datum/asset/simple/portraits
@@ -474,10 +475,10 @@
assets = list()
/datum/asset/simple/portraits/New()
- if(!SSpersistence.paintings || !SSpersistence.paintings[tab] || !length(SSpersistence.paintings[tab]))
+ if(!length(SSpersistent_paintings.paintings[tab]))
return
- for(var/p in SSpersistence.paintings[tab])
- var/list/portrait = p
+
+ for(var/list/portrait as anything in SSpersistent_paintings.paintings[tab])
var/png = "data/paintings/[tab]/[portrait["md5"]].png"
if(fexists(png))
var/asset_name = "[tab]_[portrait["md5"]]"
@@ -490,7 +491,7 @@
/datum/asset/spritesheet/supplypods
name = "supplypods"
-/datum/asset/spritesheet/supplypods/register()
+/datum/asset/spritesheet/supplypods/create_spritesheets()
for (var/style in 1 to length(GLOB.podstyles))
var/icon_file = 'icons/obj/supplypods.dmi'
if (style == STYLE_SEETHROUGH)
@@ -515,4 +516,3 @@
glow = "pod_glow_[glow]"
podIcon.Blend(icon(icon_file, glow), ICON_OVERLAY)
Insert("pod_asset[style]", podIcon)
- return ..()
diff --git a/code/modules/asset_cache/transports/asset_transport.dm b/code/modules/asset_cache/transports/asset_transport.dm
index f5a1af4f0573..555d96e76a6a 100644
--- a/code/modules/asset_cache/transports/asset_transport.dm
+++ b/code/modules/asset_cache/transports/asset_transport.dm
@@ -137,7 +137,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 58dfcfdd398a..3438d269e823 100644
--- a/code/modules/awaymissions/capture_the_flag.dm
+++ b/code/modules/awaymissions/capture_the_flag.dm
@@ -265,7 +265,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.copy_to(M)
+ new_team_member.prefs.apply_prefs_to(M)
M.set_species(/datum/species/synth)
M.key = new_team_member.key
M.faction += team
diff --git a/code/modules/awaymissions/corpse.dm b/code/modules/awaymissions/corpse.dm
index 769543dcae36..2e8c3a1bee82 100644
--- a/code/modules/awaymissions/corpse.dm
+++ b/code/modules/awaymissions/corpse.dm
@@ -320,7 +320,7 @@
/obj/effect/mob_spawn/human/doctor
name = "Doctor"
- outfit = /datum/outfit/job/doctor
+ outfit = /datum/outfit/job/doctor/dead
/obj/effect/mob_spawn/human/doctor/alive
diff --git a/code/modules/balloon_alert/balloon_alert.dm b/code/modules/balloon_alert/balloon_alert.dm
index 43182617624d..c830fcda189e 100644
--- a/code/modules/balloon_alert/balloon_alert.dm
+++ b/code/modules/balloon_alert/balloon_alert.dm
@@ -19,7 +19,7 @@
/atom/proc/balloon_or_message(mob/viewer, alert, message)
SHOULD_NOT_SLEEP(TRUE)
- if(viewer.client.prefs.disable_balloon_alerts)
+ if(viewer.client.prefs.read_preference(/datum/preference/toggle/disable_balloon_alerts))
INVOKE_ASYNC(.proc/to_chat, viewer, message)
else
INVOKE_ASYNC(src, .proc/balloon_alert_perform, viewer, message ? message : alert)
diff --git a/code/modules/client/client_defines.dm b/code/modules/client/client_defines.dm
index 82a6c801326c..bc093d8e6658 100644
--- a/code/modules/client/client_defines.dm
+++ b/code/modules/client/client_defines.dm
@@ -99,8 +99,6 @@
///world.timeofday they connected
var/connection_timeofday
- ///If the client is currently in player preferences
- var/inprefs = FALSE
///Used for limiting the rate of topic sends by the client to avoid abuse
var/list/topiclimiter
///Used for limiting the rate of clicks sends by the client to avoid abuse
@@ -169,3 +167,6 @@
var/next_move_dir_add
/// On next move, subtract this dir from the move that would otherwise be done
var/next_move_dir_sub
+
+ /// 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 0161a78393f8..b322ce725494 100644
--- a/code/modules/client/client_procs.dm
+++ b/code/modules/client/client_procs.dm
@@ -38,7 +38,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
if(!usr || usr != mob) //stops us calling Topic for somebody else's client. Also helps prevent usr=null
return
- if(src.prefs && src.prefs.afreeze && !href_list["priv_msg"] && href_list["_src_"] != "chat" && !src.holder) //yogs start - afreeze
+ if(src.prefs && src.afreeze && !href_list["priv_msg"] && href_list["_src_"] != "chat" && !src.holder) //yogs start - afreeze
to_chat(src, span_userdanger("You have been frozen by an administrator."))
return //yogs end
@@ -140,13 +140,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
hsrc = mob
if("mentor") // YOGS - Mentor stuff
hsrc = mentor_datum // YOGS - Mentor stuff
- 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)
@@ -168,12 +161,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
..() //redirect to hsrc.Topic()
-/client/proc/is_content_unlocked()
- if(!is_donator(src)) // yogs - changed this to is_donator so admins get donor perks
- 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
-
/client/proc/handle_spam_prevention(message, mute_type)
//Increment message count
total_message_count += 1
@@ -246,7 +233,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
// Instantiate tgui panel
tgui_panel = new(src)
- tgui_panel.send_connected()
+ //tgui_panel.send_connected()
GLOB.ahelp_tickets.ClientLogin(src)
var/connecting_admin = GLOB.permissions.load_permissions_for(src) //because de-admined admins connecting should be treated like admins.
@@ -267,12 +254,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
if(fexists(roundend_report_file()))
add_verb(src, /client/proc/show_previous_roundend_report)
@@ -311,20 +298,13 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
player_details.byond_version = full_version
GLOB.player_details[ckey] = player_details
- // yogs start - Donor stuff
- if(ckey in GLOB.donators)
- prefs.unlock_content |= 2
- else
- prefs.unlock_content &= ~2 // is_donator relies on prefs.unlock_content
-
- if(is_donator(src))
+ if (prefs.unlock_content & DONOR_YOGS)
src.add_donator_verbs()
else
if(prefs.yogtoggles & QUIET_ROUND)
prefs.yogtoggles &= ~QUIET_ROUND
prefs.save_preferences()
-
- // yogs end
+
. = ..() //calls mob.Login()
if (byond_version >= 512)
@@ -479,11 +459,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
if (verbpath.name[1] != "@")
new child(src)
- for (var/thing in prefs.menuoptions)
- var/datum/verbs/menu/menuitem = GLOB.menulist[thing]
- if (menuitem)
- menuitem.Load_checked(src)
- view_size = new(src, getScreenSize(prefs.widescreenpref))
+ view_size = new(src, getScreenSize(prefs.read_preference(/datum/preference/toggle/widescreen)))
view_size.resetFormat()
view_size.setZoomMode()
Master.UpdateTickRate()
@@ -860,6 +836,10 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
ip_intel = res.intel
/client/Click(atom/object, atom/location, control, params)
+ if(src.afreeze)
+ to_chat(src, span_userdanger("You have been frozen by an administrator."))
+ return
+
var/ab = FALSE
var/list/L = params2list(params)
@@ -907,7 +887,7 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
to_chat(src, span_danger("Your previous click was ignored because you've done too many in a second"))
return
- if (prefs.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.
@@ -1004,8 +984,8 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
if(!D?.key_bindings)
return
movement_keys = list()
- for(var/key in D.key_bindings)
- for(var/kb_name in D.key_bindings[key])
+ for(var/kb_name in D.key_bindings)
+ for(var/key in D.key_bindings[kb_name])
switch(kb_name)
if("North")
movement_keys[key] = NORTH
@@ -1045,12 +1025,12 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
QDEL_NULL(mob.hud_used)
mob.create_mob_hud()
mob.hud_used.show_hud(mob.hud_used.hud_version)
- mob.hud_used.update_ui_style(ui_style2icon(prefs.UI_style))
+ mob.hud_used.update_ui_style(ui_style2icon(prefs.read_preference(/datum/preference/choiced/ui_style)))
if (isliving(mob))
var/mob/living/M = mob
M.update_damage_hud()
- if (prefs.auto_fit_viewport)
+ if (prefs.read_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()
@@ -1067,26 +1047,6 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
if(prefs && prefs.chat_toggles & CHAT_PULLR)
to_chat(src, announcement)
-/client/proc/show_character_previews(mutable_appearance/MA)
- var/pos = 0
- for(var/D in GLOB.cardinals)
- pos++
- var/atom/movable/screen/O = LAZYACCESS(char_render_holders, "[D]")
- if(!O)
- O = new
- LAZYSET(char_render_holders, "[D]", O)
- screen |= O
- O.appearance = MA
- O.dir = D
- O.screen_loc = "character_preview_map:0,[pos]"
-
-/client/proc/clear_character_previews()
- for(var/index in char_render_holders)
- var/atom/movable/screen/S = char_render_holders[index]
- screen -= S
- qdel(S)
- char_render_holders = null
-
/// compiles a full list of verbs and sends it to the browser
/client/proc/init_verbs()
if(IsAdminAdvancedProcCall())
@@ -1114,3 +1074,11 @@ GLOBAL_LIST_INIT(blacklisted_builds, list(
return
to_chat(src, span_userdanger("Statpanel failed to load, click
here to reload the panel "))
tgui_panel.initialize()
+
+/client/verb/stop_client_sounds()
+ set name = "Stop Sounds"
+ set category = "OOC"
+ 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"))
diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm
index 5b7ce54921d8..bf07326dd2b6 100644
--- a/code/modules/client/preferences.dm
+++ b/code/modules/client/preferences.dm
@@ -14,1326 +14,485 @@ GLOBAL_LIST_EMPTY(preferences_datums)
//game-preferences
var/lastchangelog = "" //Saved changlog filesize to detect if there was a change
- var/ooccolor = "#c43b23"
- var/asaycolor = null
- var/enable_tips = TRUE
- var/tip_delay = 500 //tip delay in milliseconds
//Antag preferences
var/list/be_special = list() //Special role selection
- var/tmp/old_be_special = 0 //Bitflag version of be_special, used to update old savefiles and nothing more
- //If it's 0, that's good, if it's anything but 0, the owner of this prefs file's antag choices were,
- //autocorrected this round, not that you'd need to check that.
-
- var/UI_style = null
- var/buttons_locked = FALSE
- var/hotkeys = TRUE
- // Custom Keybindings
+
+ /// Custom keybindings. Map of keybind names to keyboard inputs.
+ /// For example, by default would have "swap_hands" -> list("X")
var/list/key_bindings = list()
- var/tgui_fancy = TRUE
- var/tgui_lock = FALSE
- var/windowflashing = TRUE
+
+ /// 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/toggles = TOGGLES_DEFAULT
var/db_flags
var/chat_toggles = TOGGLES_DEFAULT_CHAT
var/extra_toggles = TOGGLES_DEFAULT_EXTRA
+ var/yogtoggles = YOGTOGGLES_DEFAULT
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/ghost_hud = 1
- var/inquisitive_ghost = 1
- var/allow_midround_antag = 1
- var/preferred_map = null
- var/pda_style = MONO
- var/pda_color = "#808000"
- var/pda_theme = PDA_THEME_TITLE_NTOS
- var/id_in_pda = FALSE
- var/show_credits = TRUE
- var/uses_glasses_colour = 0
-
- var/list/player_alt_titles = new()
-
- ///Whether emotes will be displayed on runechat. Requires chat_on_map to have effect. Boolean.
- var/see_rc_emotes = TRUE
-
-
- //character preferences
- var/real_name //our character's name
- var/be_random_name = 0 //whether we'll have a random name every round
- var/be_random_body = 0 //whether we'll have a random body every round
- var/gender = MALE //gender of character (well duh)
- var/age = 30 //age of character
- var/underwear = "Nude" //underwear type
- var/undershirt = "Nude" //undershirt type
- var/socks = "Nude" //socks type
- var/backbag = DBACKPACK //backpack type
- var/jumpsuit_style = PREF_SUIT //suit/skirt
- var/hair_style = "Bald" //Hair type
- var/hair_color = "000" //Hair color
- var/facial_hair_style = "Shaved" //Face hair type
- var/facial_hair_color = "000" //Facial hair color
- var/skin_tone = "caucasian1" //Skin color
- var/eye_color = "000" //Eye color
- var/datum/species/pref_species = new /datum/species/human() //Mutant race
- var/list/features = list("mcolor" = "FFF", "gradientstyle" = "None", "gradientcolor" = "000", "ethcolor" = "9c3030", "pretcolor" = "FFFFFF", "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", "tail_polysmorph" = "Polys", "teeth" = "None", "dome" = "None", "dorsal_tubes" = "No", "ethereal_mark" = "None", "pod_hair" = "Cabbage", "pod_flower" = "Cabbage", "ipc_screen" = "Blue", "ipc_antenna" = "None", "ipc_chassis" = "Morpheus Cyberkinetics(Greyscale)","plasmaman_helmet" = "None")
- var/list/genders = list(MALE, FEMALE, PLURAL)
- var/list/friendlyGenders = list("Male" = "male", "Female" = "female", "Other" = "plural")
-
- var/list/random_locks = list()
-
- var/list/custom_names = list()
- var/preferred_ai_core_display = "Blue"
- var/prefered_security_department = SEC_DEPT_RANDOM
- var/prefered_engineering_department = ENG_DEPT_RANDOM
+
+ var/list/player_alt_titles = list()
+
+ var/list/randomise = list()
//Quirk list
var/list/all_quirks = list()
- var/mood_tail_wagging = TRUE
-
//Job preferences 2.0 - indexed by job title , no key or value implies never
var/list/job_preferences = list()
- // Want randomjob if preferences already filled - Donkie
- var/joblessrole = BERANDOMJOB //defaults to 1 for fewer assistants
-
- // 0 = character settings, 1 = game preferences
- var/current_tab = 0
+ /// The current window, PREFERENCE_TAB_* in [`code/__DEFINES/preferences.dm`]
+ var/current_window = PREFERENCE_TAB_CHARACTER_PREFERENCES
var/unlock_content = 0
var/list/ignoring = list()
- var/clientfps = 40
+ var/list/exp = list()
- var/parallax
+ var/action_buttons_screen_locs = list()
- var/ambientocclusion = TRUE
- ///Should we automatically fit the viewport?
- var/auto_fit_viewport = TRUE
- ///Should we be in the widescreen mode set by the config?
- var/widescreenpref = TRUE
- ///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/uplink_spawn_loc = UPLINK_PDA
+ /// A preview of the current character
+ var/atom/movable/screen/character_preview_view/character_preview_view
- var/skillcape = 1 /// Old skillcape value
- var/skillcape_id = "None" /// Typepath of selected skillcape, null for none
+ /// Icon for the preview background
+ var/icon/background = "floor"
- var/map = 1
- var/flare = 1
+ /// A list of instantiated middleware
+ var/list/datum/preference_middleware/middleware = list()
- var/bar_choice = "Random"
+ /// The savefile relating to core preferences, PREFERENCE_PLAYER
+ var/savefile/game_savefile
- var/list/exp = list()
- var/list/menuoptions
+ /// The savefile relating to character preferences, PREFERENCE_CHARACTER
+ var/savefile/character_savefile
- var/action_buttons_screen_locs = list()
+ /// A list of keys that have been updated since the last save.
+ var/list/recently_updated_keys = list()
- var/chat_on_map = TRUE
- var/max_chat_length = CHAT_MESSAGE_MAX_LENGTH
- var/see_chat_non_mob = TRUE
- /// If we have persistent scars enabled
- var/persistent_scars = TRUE
+ /// A cache of preference entries to values.
+ /// Used to avoid expensive READ_FILE every time a preference is retrieved.
+ var/value_cache = list()
- var/disable_alternative_announcers = FALSE
- var/icon/background = "floor"
- var/list/background_options = list(
- "floor" = "Default Tile",
- "white" = "Default White Tile",
- "darkfull" = "Default Dark Tile",
- "wood" = "Wood",
- "rockvault" = "Rock Vault",
- "grass4" = "Grass",
- "black" = "Pure Black",
- "grey" = "Pure Grey",
- "pure_white" = "Pure White"
- )
+ /// If set to TRUE, will update character_profiles on the next ui_data tick.
+ var/tainted_character_profiles = FALSE
- var/disable_balloon_alerts = FALSE
+/datum/preferences/Destroy(force, ...)
+ QDEL_NULL(character_preview_view)
+ QDEL_LIST(middleware)
+ value_cache = null
+ return ..()
/datum/preferences/New(client/C)
parent = C
- for(var/custom_name_id in GLOB.preferences_custom_names)
- custom_names[custom_name_id] = get_default_name(custom_name_id)
+ for (var/middleware_type in subtypesof(/datum/preference_middleware))
+ middleware += new middleware_type(src)
- UI_style = GLOB.available_ui_styles[1]
if(istype(C))
if(!IsGuestKey(C.key))
load_path(C.ckey)
- unlock_content |= C.IsByondMember() // yogs - Donor features
- if(unlock_content)
- max_save_slots += 2
- // yogs start - Donor features
- if(is_donator(C) || (C.ckey in get_donators())) // the Latter handles race cases where the prefs are not fully loaded in, or GLOB.donators hasn't loaded in yet
- max_save_slots += DONOR_CHARACTER_SLOTS
- // yogs end
+
+ if (C.IsByondMember())
+ unlock_content |= DONOR_BYOND
+
+ // the latter handles race cases where the prefs are not fully loaded in, or GLOB.donators hasn't loaded in yet
+ if(is_donator(C) || (C.ckey in get_donators()))
+ unlock_content |= DONOR_YOGS
+
+ // give save slots to donors
+ if (unlock_content & DONOR_YOGS)
+ max_save_slots += DONOR_YOGS_SLOTS + DONOR_BYOND_SLOTS
+ else if (unlock_content & DONOR_BYOND)
+ max_save_slots += DONOR_BYOND_SLOTS
+
+ // give them default keybinds and update their movement keys
+ key_bindings = deepCopyList(GLOB.default_hotkeys)
+ key_bindings_by_key = get_key_bindings_by_key(key_bindings)
+ randomise = get_default_randomization()
+
var/loaded_preferences_successfully = load_preferences()
if(loaded_preferences_successfully)
if(load_character())
return
//we couldn't load character data so just randomize the character appearance + name
- random_character() //let's create a random character then - rather than a fat, bald and naked man.
- key_bindings = deepCopyList(GLOB.hotkey_keybinding_list_by_key) // give them default keybinds and update their movement keys
- C?.set_macros()
- real_name = pref_species.random_name(gender,1)
+ randomise_appearance_prefs() //let's create a random character then - rather than a fat, bald and naked man.
+ if(C)
+ apply_all_client_preferences()
+ C.set_macros()
+
if(!loaded_preferences_successfully)
save_preferences()
save_character() //let's save this new random character so it doesn't keep generating new ones.
- menuoptions = list()
- return
-
-#define APPEARANCE_CATEGORY_COLUMN "
"
-#define MAX_MUTANT_ROWS 4
-/datum/preferences/proc/ShowChoices(mob/user)
- if(!user || !user.client)
+/datum/preferences/ui_interact(mob/user, datum/tgui/ui)
+ if(!SSjob.initialized)
+ tgui_alert(user, "You cannot open the preferences menu before the job subsystem is initialized!")
return
- if(!SSjob || (SSjob.occupations.len <= 0))
- to_chat(user, span_notice("The job SSticker is not yet finished creating jobs, please try again later"))
- return
-
- update_preview_icon()
- var/list/dat = list("")
-
- dat += "Character Settings "
- dat += "Game Preferences "
- dat += "OOC Preferences "
- dat += "Donator Preferences " // yogs - Donor features
- dat += "Keybindings " // yogs - Custom keybindings
-
- if(!path)
- dat += "Please create an account to save your preferences
"
-
- dat += " "
-
- dat += " "
-
- switch(current_tab)
- if (0) // Character Settings#
- if(path)
- var/savefile/S = new /savefile(path)
- if(S)
- dat += ""
- var/name
- var/unspaced_slots = 0
- for(var/i=1, i<=max_save_slots, i++)
- unspaced_slots++
- if(unspaced_slots > 4)
- dat += " "
- unspaced_slots = 0
- S.cd = "/character[i]"
- S["real_name"] >> name
- if(!name)
- name = "Character[i]"
- 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: [all_quirks.len ? all_quirks.Join(", ") : "None"] "
- dat += "Identity "
- dat += ""
-
- dat += "Body "
- dat += "Random Body "
- dat += "Always Random Body: [be_random_body ? "Yes" : "No"] "
- dat += "Unlock all "
- dat += "Lock all "
- dat += "Background: [background_options[background]] "
-
- dat += ""
-
- dat += "Species: [pref_species.name] "
- dat += "[random_locks["species"] ? "Unlock" : "Lock"] "
-
- dat += "Underwear: [underwear] "
- dat += "[random_locks["underwear"] ? "Unlock" : "Lock"] "
-
- dat += "Undershirt: [undershirt] "
- dat += "[random_locks["undershirt"] ? "Unlock" : "Lock"] "
-
- dat += "Socks: [socks] "
- dat += "[random_locks["socks"] ? "Unlock" : "Lock"] "
-
- dat += "Backpack: [backbag] "
- dat += "Jumpsuit: [jumpsuit_style] "
- dat += "[random_locks["bag"] ? "Unlock" : "Lock"] "
- if((HAS_FLESH in pref_species.species_traits) || (HAS_BONE in pref_species.species_traits))
- dat += "Temporal Scarring: [(persistent_scars) ? "Enabled" : "Disabled"] "
- dat += "Clear scar slots "
- dat += "Uplink Spawn Location: [uplink_spawn_loc] "
-
- var/use_skintones = pref_species.use_skintones
- if(use_skintones)
-
- dat += APPEARANCE_CATEGORY_COLUMN
+ // If you leave and come back, re-register the character preview
+ if (!isnull(character_preview_view) && !(character_preview_view in user.client?.screen))
+ user.client?.register_map_obj(character_preview_view)
- dat += "Skin Tone "
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "PreferencesMenu")
+ ui.set_autoupdate(FALSE)
+ ui.open()
- dat += "[skin_tone] "
- dat += "[random_locks["underwear"] ? "Unlock" : "Lock"] "
+ // HACK: Without this the character starts out really tiny because of some BYOND bug.
+ // You can fix it by changing a preference, so let's just forcably update the body to emulate this.
+ addtimer(CALLBACK(character_preview_view, /atom/movable/screen/character_preview_view/proc/update_body), 1 SECONDS)
- var/mutant_colors
- if((((MUTCOLORS in pref_species.species_traits) && !(NOCOLORCHANGE in pref_species.species_traits))) || (MUTCOLORS_PARTSONLY in pref_species.species_traits))
+/datum/preferences/ui_state(mob/user)
+ return GLOB.always_state
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
+// 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
- dat += "Mutant Color "
+/datum/preferences/ui_data(mob/user)
+ var/list/data = list()
- dat += " "
- dat += "Change [random_locks["mcolor"] ? "Unlock" : "Lock"] "
+ if (isnull(character_preview_view))
+ character_preview_view = create_character_preview_view(user)
+ else if (character_preview_view.client != parent)
+ // The client re-logged, and doing this when they log back in doesn't seem to properly
+ // carry emissives.
+ character_preview_view.register_to_client(parent)
- mutant_colors = TRUE
+ if (tainted_character_profiles)
+ data["character_profiles"] = create_character_profiles()
+ tainted_character_profiles = FALSE
- if(istype(pref_species, /datum/species/ethereal)) //not the best thing to do tbf but I dont know whats better.
+ data["character_preferences"] = compile_character_preferences(user)
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
+ data["active_slot"] = default_slot
- dat += "Ethereal Color "
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ data += preference_middleware.get_ui_data(user)
- dat += " "
- dat += "Change [random_locks["ethcolor"] ? "Unlock" : "Lock"] "
+ return data
+/datum/preferences/ui_static_data(mob/user)
+ var/list/data = list()
- if(istype(pref_species, /datum/species/preternis)) //fuck, i know even less than you, i've just been copy pasting thus far.
+ data["character_profiles"] = create_character_profiles()
- if(!use_skintones)
- dat += APPEARANCE_CATEGORY_COLUMN
+ data["character_preview_view"] = character_preview_view.assigned_map
+ data["overflow_role"] = SSjob.GetJob(SSjob.overflow_role).title
+ data["window"] = current_window
- dat += "Preternis Color "
+ data["content_unlocked"] = unlock_content
+ data["ckey"] = lowertext(user.client.ckey)
- dat += " "
- dat += "Change [random_locks["pretcolor"] ? "Unlock" : "Lock"] "
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ data += preference_middleware.get_ui_static_data(user)
- if((EYECOLOR in pref_species.species_traits) || !(NOEYESPRITES in pref_species.species_traits))
+ return data
- if(!use_skintones && !mutant_colors)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Eye Color "
-
- dat += " "
- dat += "Change [random_locks["eyes"] ? "Unlock" : "Lock"] "
-
- dat += ""
- else if(use_skintones || mutant_colors)
- dat += ""
-
- if(HAIR in pref_species.species_traits)
-
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Hair Style "
-
- dat += "[hair_style] "
- dat += "[random_locks["hair_style"] ? "Unlock" : "Lock"] "
-
- dat += "< > "
-
- dat += " Change "
- dat += "[random_locks["hair"] ? "Unlock" : "Lock"] "
-
- dat += "Facial Hair Style "
-
- dat += "[facial_hair_style] "
- dat += "[random_locks["facial_hair_style"] ? "Unlock" : "Lock"] "
-
- dat += "< > "
-
- dat += " Change "
- dat += "[random_locks["facial"] ? "Unlock" : "Lock"] "
-
- dat += "Hair Gradient "
-
- dat += "[features["gradientstyle"]] "
- dat += "[random_locks["gradientstyle"] ? "Unlock" : "Lock"] "
-
- dat += "< > "
-
- dat += " Change "
- dat += "[random_locks["gradientcolor"] ? "Unlock" : "Lock"] "
-
- dat += ""
-
- //Mutant stuff
- var/mutant_category = 0
-
- if("tail_lizard" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Tail "
-
- dat += "[features["tail_lizard"]] "
- dat += "[random_locks["tail_lizard"] ? "Unlock" : "Lock"] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("tail_polysmorph" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Tail "
-
- dat += "[features["tail_polysmorph"]] "
- dat += "[random_locks["tail_polysmorph"] ? "Unlock" : "Lock"] "
+/datum/preferences/ui_assets(mob/user)
+ var/list/assets = list(
+ get_asset_datum(/datum/asset/spritesheet/preferences),
+ get_asset_datum(/datum/asset/json/preferences),
+ )
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ assets += preference_middleware.get_ui_assets()
- if("snout" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ return assets
- dat += "Snout "
+/datum/preferences/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if (.)
+ return
- dat += "[features["snout"]] "
- dat += "[random_locks["snout"] ? "Unlock" : "Lock"] "
+ switch (action)
+ if ("change_slot")
+ // Save existing character
+ save_character()
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ // SAFETY: `load_character` performs sanitization the slot number
+ if (!load_character(params["slot"]))
+ tainted_character_profiles = TRUE
+ randomise_appearance_prefs()
+ save_character()
- if("horns" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ preference_middleware.on_new_character(usr)
- dat += "Horns "
+ character_preview_view.update_body()
- dat += "[features["horns"]] "
- dat += "[random_locks["horns"] ? "Unlock" : "Lock"] "
+ return TRUE
+ if ("rotate")
+ character_preview_view.dir = turn(character_preview_view.dir, -90)
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ return TRUE
+ if ("cycle")
+ background = next_list_item(background, GLOB.preview_backgrounds)
+ character_preview_view.update_body()
- if("frills" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ return TRUE
+ if ("set_preference")
+ var/requested_preference_key = params["preference"]
+ var/value = params["value"]
- dat += "Frills "
+ for (var/datum/preference_middleware/preference_middleware as anything in middleware)
+ if (preference_middleware.pre_set_preference(usr, requested_preference_key, value))
+ return TRUE
- dat += "[features["frills"]] "
- dat += "[random_locks["frills"] ? "Unlock" : "Lock"] "
+ var/datum/preference/requested_preference = GLOB.preference_entries_by_key[requested_preference_key]
+ if (isnull(requested_preference))
+ return FALSE
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ // SAFETY: `update_preference` performs validation checks
+ if (!update_preference(requested_preference, value))
+ return FALSE
- if("spines" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ if (istype(requested_preference, /datum/preference/name))
+ tainted_character_profiles = TRUE
- dat += "Spines "
+ return TRUE
+ if ("set_color_preference")
+ var/requested_preference_key = params["preference"]
- dat += "[features["spines"]] "
- dat += "[random_locks["spines"] ? "Unlock" : "Lock"] "
+ var/datum/preference/requested_preference = GLOB.preference_entries_by_key[requested_preference_key]
+ if (isnull(requested_preference))
+ return FALSE
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ if (!istype(requested_preference, /datum/preference/color) \
+ && !istype(requested_preference, /datum/preference/color_legacy) \
+ )
+ return FALSE
- if("body_markings" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ var/default_value = read_preference(requested_preference.type)
+ if (istype(requested_preference, /datum/preference/color_legacy))
+ default_value = expand_three_digit_color(default_value)
- dat += "Body Markings "
+ // Yielding
+ var/new_color = input(
+ usr,
+ "Select new color",
+ null,
+ default_value || COLOR_WHITE,
+ ) as color | null
- dat += "[features["body_markings"]] "
- dat += "[random_locks["body_markings"] ? "Unlock" : "Lock"] "
+ if (!new_color)
+ return FALSE
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ if (!update_preference(requested_preference, new_color))
+ return FALSE
- if(("legs" in pref_species.default_features) && !(DIGITIGRADE in pref_species.species_traits))
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ return TRUE
- dat += "Legs "
+ 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)
- dat += "[features["legs"]] "
- dat += "[random_locks["legs"] ? "Unlock" : "Lock"] "
+ return FALSE
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+/datum/preferences/ui_close(mob/user)
+ save_character()
+ save_preferences()
+ QDEL_NULL(character_preview_view)
- if("moth_wings" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+/datum/preferences/Topic(href, list/href_list)
+ . = ..()
+ if (.)
+ return
- dat += "Moth wings "
+ if (href_list["open_keybindings"])
+ current_window = PREFERENCE_TAB_KEYBINDINGS
+ update_static_data(usr)
+ ui_interact(usr)
+ return TRUE
- dat += "[features["moth_wings"]] "
- dat += "[random_locks["moth_wings"] ? "Unlock" : "Lock"] "
+/datum/preferences/Topic(href, list/href_list)
+ . = ..()
+ if (.)
+ return
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ if (href_list["open_keybindings"])
+ current_window = PREFERENCE_TAB_KEYBINDINGS
+ update_static_data(usr)
+ ui_interact(usr)
+ return TRUE
- if("teeth" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+/datum/preferences/proc/create_character_preview_view(mob/user)
+ character_preview_view = new(null, src, user.client)
+ character_preview_view.update_body()
+ character_preview_view.register_to_client(user.client)
- dat += "Teeth "
+ return character_preview_view
- dat += "[features["teeth"]] "
- dat += "[random_locks["teeth"] ? "Unlock" : "Lock"] "
+/datum/preferences/proc/compile_character_preferences(mob/user)
+ var/list/preferences = list()
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (!preference.is_accessible(src))
+ continue
- if("dome" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ LAZYINITLIST(preferences[preference.category])
- dat += "Dome "
+ var/value = read_preference(preference.type)
+ var/data = preference.compile_ui_data(user, value)
- dat += "[features["dome"]] "
- dat += "[random_locks["dome"] ? "Unlock" : "Lock"] "
+ preferences[preference.category][preference.savefile_key] = data
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ 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
- if("dorsal_tubes" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ for (var/category in append_character_preferences)
+ if (category in preferences)
+ preferences[category] += append_character_preferences[category]
+ else
+ preferences[category] = append_character_preferences[category]
- dat += "Dorsal Tubes "
+ return preferences
- dat += "[features["dorsal_tubes"]] "
- dat += "[random_locks["dorsal_tubes"] ? "Unlock" : "Lock"] "
+/// Applies all PREFERENCE_PLAYER preferences
+/datum/preferences/proc/apply_all_client_preferences()
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (preference.savefile_identifier != PREFERENCE_PLAYER)
+ continue
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ value_cache -= preference.type
+ preference.apply_to_client(parent, read_preference(preference.type))
- if("ethereal_mark" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+// This is necessary because you can open the set preferences menu before
+// the atoms SS is done loading.
+INITIALIZE_IMMEDIATE(/atom/movable/screen/character_preview_view)
- dat += "Ethereal Mark "
+/// A preview of a character for use in the preferences menu
+/atom/movable/screen/character_preview_view
+ name = "character_preview"
+ del_on_map_removal = FALSE
+ layer = GAME_PLANE
+ plane = GAME_PLANE
- dat += "[features["ethereal_mark"]] "
- dat += "[random_locks["ethereal_mark"] ? "Unlock" : "Lock"] "
+ /// The body that is displayed
+ var/mob/living/carbon/human/dummy/body
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ /// The preferences this refers to
+ var/datum/preferences/preferences
- if("pod_hair" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ var/list/plane_masters = list()
- dat += "Head Vegitation Style "
- dat += "[features["pod_hair"]] "
- dat += "[random_locks["pod_hair"] ? "Unlock" : "Lock"] "
- dat += " Change "
- dat += "[random_locks["hair"] ? "Unlock" : "Lock"] "
+ /// The client that is watching this view
+ var/client/client
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+/atom/movable/screen/character_preview_view/Initialize(mapload, datum/preferences/preferences, client/client)
+ . = ..()
- if("pod_flower" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
- dat += "Head Flowers Color "
- dat += " Change "
- dat += "[random_locks["facial"] ? "Unlock" : "Lock"] "
+ assigned_map = "character_preview_[REF(src)]"
+ set_position(1, 1)
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ src.preferences = preferences
- if("tail_human" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+/atom/movable/screen/character_preview_view/Destroy()
+ QDEL_NULL(body)
- dat += "Tail "
+ for (var/plane_master in plane_masters)
+ client?.screen -= plane_master
+ qdel(plane_master)
- dat += "[features["tail_human"]] "
- dat += "[random_locks["tail_human"] ? "Unlock" : "Lock"] "
+ client?.clear_map(assigned_map)
+ client?.screen -= src
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ preferences?.character_preview_view = null
- if("ipc_screen" in pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ client = null
+ plane_masters = null
+ preferences = null
- dat += "Screen Style "
+ return ..()
- dat += "[features["ipc_screen"]] "
+/// Updates the currently displayed body
+/atom/movable/screen/character_preview_view/proc/update_body()
+ if (isnull(body))
+ create_body()
+ else
+ body.wipe_state()
+ appearance = preferences.render_new_preview_appearance(body)
- dat += " Change "
+/atom/movable/screen/character_preview_view/proc/create_body()
+ QDEL_NULL(body)
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
+ body = new
- if("ipc_antenna" in pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
+ // Without this, it doesn't show up in the menu
+ body.appearance_flags &= ~KEEP_TOGETHER
- dat += "Antenna Style "
+/// Registers the relevant map objects to a client
+/atom/movable/screen/character_preview_view/proc/register_to_client(client/client)
+ QDEL_LIST(plane_masters)
- dat += "[features["ipc_antenna"]] "
+ src.client = client
- dat += " Change "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("ipc_chassis" in pref_species.mutant_bodyparts)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Chassis Style "
-
- dat += "[features["ipc_chassis"]] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("ears" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Ears "
-
- dat += "[features["ears"]] "
- dat += "[random_locks["ears"] ? "Unlock" : "Lock"] "
-
- mutant_category++
- if(mutant_category >= MAX_MUTANT_ROWS)
- dat += ""
- mutant_category = 0
-
- if("plasmaman_helmet" in pref_species.default_features)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Helmet Style "
-
- dat += "[features["plasmaman_helmet"]] "
- dat += "[random_locks["plasmaman_helmet"] ? "Unlock" : "Lock"] "
-
- if(CONFIG_GET(flag/join_with_mutant_humans))
-
- if("wings" in pref_species.default_features && GLOB.r_wings_list.len >1)
- if(!mutant_category)
- dat += APPEARANCE_CATEGORY_COLUMN
-
- dat += "Wings "
-
- dat += "[features["wings"]] "
- dat += "[random_locks["wings"] ? "Unlock" : "Lock"] "
-
- 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(2) //OOC Preferences
- dat += ""
- // yogs start - Donor features
- if (3) //Donator preferences
- dat += ""
- dat += "Donator Preferences "
- if(is_donator(user.client))
- dat += "Quiet round: [(src.yogtoggles & QUIET_ROUND) ? "Yes" : "No"] "
- dat += "Wear fancy hat as borg: "
- dat += "[borg_hat ? "Yes" : "No"] "
- dat += "Fancy Hat: "
- ///This is the typepath of the donor's hat that they may choose to spawn with.
- var/typehat = donor_hat
- var/temp_hat = donor_hat ? (new typehat()) : "None selected"
- dat += "Pick [temp_hat] "
- if(donor_hat)
- qdel(temp_hat)
- dat += "Fancy Item: "
- ///Whatever item the donator has chosen to apply.
- var/typeitem = donor_item
- var/temp_item = donor_item ? (new typeitem()) : "None selected"
- dat += "Pick [temp_item] "
- if(donor_item)
- qdel(temp_item)
- dat += "Fancy PDA: "
- dat += "[GLOB.donor_pdas[donor_pda]] "
- dat += "Purrbation (Humans only) "
- dat += "[purrbation ? "Yes" : "No"] "
- else
- dat += "Donate here "
- dat += "
"
- // yogs end
- if (4) // Keybindings
- // 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] += list(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]
- kb_categories[kb.category] += list(kb)
-
- dat += ""
-
- for (var/category in kb_categories)
- dat += "[category] "
- for (var/i in kb_categories[category])
- var/datum/keybinding/kb = i
- if(!length(user_binds[kb.name]) || user_binds[kb.name][1] == "Unbound")
- dat += "[kb.full_name] Unbound "
- var/list/default_keys = hotkeys ? kb.hotkey_keys : kb.classic_keys
- if(LAZYLEN(default_keys))
- dat += "| Default: [default_keys.Join(", ")]"
- dat += " "
- else
- var/bound_key = user_binds[kb.name][1]
- dat += "[kb.full_name] [bound_key] "
- for(var/bound_key_index in 2 to length(user_binds[kb.name]))
- bound_key = user_binds[kb.name][bound_key_index]
- dat += " | [bound_key] "
- if(length(user_binds[kb.name]) < MAX_KEYS_PER_KEYBIND)
- dat += "| Add Secondary "
- var/list/default_keys = hotkeys ? kb.classic_keys : kb.hotkey_keys
- if(LAZYLEN(default_keys))
- dat += "| Default: [default_keys.Join(", ")]"
- dat += " "
-
- dat += " "
- dat += "\[Reset to default\] "
- dat += ""
- dat += ""
-
- if(!IsGuestKey(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, 770)
- popup.set_content(dat.Join())
- popup.open(FALSE)
- onclose(user, "preferences_window", src)
-
-#undef APPEARANCE_CATEGORY_COLUMN
-#undef MAX_MUTANT_ROWS
-
-/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/SetChoices(mob/user, limit = 17, list/splitJobs = list("Research Director", "Head of Personnel"), widthPerColumn = 295, height = 620)
- if(!SSjob)
+ if (!client)
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
+ for (var/plane_master_type in subtypesof(/atom/movable/screen/plane_master))
+ var/atom/movable/screen/plane_master/plane_master = new plane_master_type
+ plane_master.screen_loc = "[assigned_map]:CENTER"
+ client?.screen |= plane_master
- 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 sortList(SSjob.occupations, /proc/cmp_job_display_asc))
-
- 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((job_preferences[overflow] == JP_LOW) && (rank != SSjob.overflow_role) && !is_banned_from(user.ckey, SSjob.overflow_role))
- HTML += "[rank] "
- continue
- // yogs start - Donor features, quiet round
- if(((rank in GLOB.command_positions) || (rank in GLOB.nonhuman_positions)) && (src.yogtoggles & QUIET_ROUND))
- HTML += "[rank] \[QUIET\] "
- continue
- // yogs end
-
- var/rank_display
- if(job.alt_titles)
- rank_display = "[GetPlayerAltTitle(job)] "
- else
- rank_display = span_dark("[rank]")
-
- if((rank in GLOB.command_positions) || (rank == "AI"))//Bold head jobs
- HTML += "[rank_display] "
- else
- HTML += rank_display
-
- 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(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
+ plane_masters += plane_master
- HTML += ""
+ client?.register_map_obj(src)
- if(rank == SSjob.overflow_role)//Overflow is special
- if(job_preferences[overflow.title] == JP_LOW)
- HTML += "Yes "
- else
- HTML += "No "
- HTML += " "
- continue
+/datum/preferences/proc/create_character_profiles()
+ var/list/profiles = list()
- HTML += "[prefLevelLabel] "
- HTML += ""
+ var/savefile/savefile = new(path)
+ for (var/index in 1 to max_save_slots)
+ // It won't be updated in the savefile yet, so just read the name directly
+ if (index == default_slot)
+ profiles += read_preference(/datum/preference/name/real_name)
+ continue
- for(var/i = 1, i < (limit - index), i += 1) // Finish the column so it is even
- HTML += "    "
+ savefile.cd = "/character[index]"
- HTML += "
"
- HTML += "
"
+ var/name
+ READ_FILE(savefile["real_name"], name)
- var/message = "Be an [SSjob.overflow_role] if preferences unavailable"
- if(joblessrole == BERANDOMJOB)
- message = "Get random job if preferences unavailable"
- else if(joblessrole == RETURNTOLOBBY)
- message = "Return to lobby if preferences unavailable"
- HTML += "[message] "
- HTML += "Reset Preferences "
+ if (isnull(name))
+ profiles += null
+ continue
- 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)
+ profiles += name
-/datum/preferences/proc/GetPlayerAltTitle(datum/job/job)
- return player_alt_titles.Find(job.title) > 0 \
- ? player_alt_titles[job.title] \
- : job.title
+ return profiles
-/datum/preferences/proc/SetPlayerAltTitle(datum/job/job, new_title)
- // remove existing entry
- if(player_alt_titles.Find(job.title))
- player_alt_titles -= job.title
- // add one if it's not default
- if(job.title != new_title)
- player_alt_titles[job.title] = new_title
-
-/datum/preferences/proc/SetJobPreferenceLevel(datum/job/job, level)
+/datum/preferences/proc/set_job_preference_level(datum/job/job, level)
if (!job)
return FALSE
- if (level == JP_HIGH) // to high
- //Set all other high to medium
- for(var/j in job_preferences)
- if(job_preferences[j] == JP_HIGH)
- job_preferences[j] = JP_MEDIUM
- //technically break here
-
- 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 (level == JP_HIGH)
+ var/datum/job/overflow_role = SSjob.overflow_role
+ var/overflow_role_title = initial(overflow_role.title)
- if (!isnum(desiredLvl))
- to_chat(user, span_danger("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(job_preferences[job.title] == JP_LOW)
- jpval = null
- else
- jpval = JP_LOW
-
- SetJobPreferenceLevel(job, jpval)
- SetChoices(user)
-
- return 1
-
-
-/datum/preferences/proc/ResetJobs()
- job_preferences = list()
-
-/datum/preferences/proc/SetQuirks(mob/user)
- if(!SSquirks)
- to_chat(user, span_danger("The quirk subsystem is still initializing! Try again in a minute."))
- return
+ 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
- var/list/dat = list()
- if(!SSquirks.quirks.len)
- dat += "The quirk subsystem hasn't finished initializing, please hold..."
- dat += "Done "
+ if(level == null)
+ job_preferences -= job.title
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: [all_quirks.len ? 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 = FALSE // Also marks whether this quirk ought to be locked at all; FALSE implies it's OK for this person to have this quirk
- for(var/_V in all_quirks)
- if(_V == quirk_name)
- has_quirk = TRUE
- if(initial(T.mood_quirk) && (CONFIG_GET(flag/disable_human_mood) && !(yogtoggles & PREF_MOOD)))//Yogs -- Adds mood to preferences
- lock_reason = "Mood is disabled."
- else
- var/datum/quirk/t = new T(no_init = TRUE)
- lock_reason = t.check_quirk(src) // Yogs -- allows for specific denial of quirks based on current preferences
- qdel(t)
- if(has_quirk)
- if(lock_reason)
- 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(lock_reason)
- 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 "
+ job_preferences[job.title] = level
- 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)
+ return TRUE
/datum/preferences/proc/GetQuirkBalance()
var/bal = 0
@@ -1349,1020 +508,25 @@ GLOBAL_LIST_EMPTY(preferences_datums)
sum++
return sum
-/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)
- // yogs start - Donor features
- if(href_list["preference"] == "donor")
- if(is_donator(user))
- var/client/C = (istype(user, /client)) ? user : user.client
- switch(href_list["task"])
- if("borghat")
- borg_hat = !borg_hat
- if("hat")
- C.custom_donator_item()
- if("item")
- C.custom_donator_item()
- if("quiet_round")
- yogtoggles ^= QUIET_ROUND
- if("pda")
- donor_pda = (donor_pda % GLOB.donor_pdas.len) + 1
- if("purrbation")
- purrbation = !purrbation
- else
- message_admins("EXPLOIT \[donor\]: [user] tried to access donor only functions (as a non-donor). Attempt made on \"[href_list["preference"]]\" -> \"[href_list["task"]]\".")
- // yogs end
- 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.permissions.admin_datums[user.ckey] || GLOB.permissions.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, span_danger("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(joblessrole)
- if(RETURNTOLOBBY)
- if(is_banned_from(user.ckey, SSjob.overflow_role))
- joblessrole = BERANDOMJOB
- else
- joblessrole = BEOVERFLOW
- if(BEOVERFLOW)
- joblessrole = BERANDOMJOB
- if(BERANDOMJOB)
- joblessrole = RETURNTOLOBBY
- SetChoices(user)
- if ("alt_title")
- var/datum/job/job = SSjob.GetJob(href_list["job"])
- if (job)
- var/choices = list(job.title) + job.alt_titles
- var/choice = input("Pick a title for [job.title].", "Character Generation", GetPlayerAltTitle(job)) as anything in choices | null
- if(choice)
- SetPlayerAltTitle(job, choice)
- 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 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, span_danger("[quirk] is incompatible with [Q]."))
- return
- var/value = SSquirks.quirk_points[quirk] // The value of the chosen quirk.
- var/balance = GetQuirkBalance()
- if(quirk in all_quirks)
- if(balance + value < 0)
- to_chat(user, span_warning("Refunding this would cause you to go below your balance!"))
- return
- all_quirks -= quirk
- else
- var/positive_count = GetPositiveQuirkCount() // Yogs -- fixes weird behaviour when at max positive quirks
- if(positive_count > MAX_QUIRKS || (positive_count == MAX_QUIRKS && value > 0)) // Yogs
- to_chat(user, span_warning("You can't have more than [MAX_QUIRKS] positive quirks!"))
- return
- if(balance - value < 0)
- to_chat(user, span_warning("You don't have enough balance to gain this quirk!"))
- return
- all_quirks += quirk
- SetQuirks(user)
- if("reset")
- all_quirks = list()
- SetQuirks(user)
- else
- SetQuirks(user)
- return TRUE
+/datum/preferences/proc/validate_quirks()
+ if(GetQuirkBalance() < 0)
+ all_quirks = list()
- switch(href_list["task"])
- if("random")
- switch(href_list["preference"])
- if("name")
- real_name = pref_species.random_name(gender,1)
- if("age")
- age = rand(AGE_MIN, AGE_MAX)
- if("hair")
- hair_color = random_short_color()
- if("hair_style")
- hair_style = random_hair_style(gender)
- if("facial")
- facial_hair_color = random_short_color()
- if("facial_hair_style")
- facial_hair_style = random_facial_hair_style(gender)
- if("underwear")
- underwear = random_underwear(gender)
- if("undershirt")
- undershirt = random_undershirt(gender)
- if("socks")
- socks = random_socks()
- if(BODY_ZONE_PRECISE_EYES)
- eye_color = random_eye_color()
- if("s_tone")
- skin_tone = random_skin_tone()
- if("bag")
- backbag = pick(GLOB.backbaglist)
- if("all")
- random_character(gender)
- if("lock")
- switch(href_list["preference"])
- if("u_all")
- for(var/i in random_locks)
- random_locks[i] = 0;
- if("l_all")
- random_locks = list(
- "gender" = gender,
- "mcolor" = 1,
- "ethcolor" = 1,
- "pretcolor" = 1,
- "tail_lizard" = 1,
- "tail_human" = 1,
- "wings" = 1,
- "snout" = 1,
- "horns" = 1,
- "ears" = 1,
- "frills" = 1,
- "spines" = 1,
- "body_markings" = 1,
- "legs" = 1,
- "caps" = 1,
- "moth_wings" = 1,
- "tail_polysmorph" = 1,
- "teeth" = 1,
- "dome" = 1,
- "dorsal_tubes" = 1,
- "ethereal_mark" = 1,
- )
- if("gender")
- random_locks["random_locks"] = gender
- else
- random_locks[href_list["preference"]] = !random_locks[href_list["preference"]]
-
- if("input")
-
- if(href_list["preference"] in GLOB.preferences_custom_names)
- ask_for_custom_name(user,href_list["preference"])
-
-
- 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 = tgui_alert(usr,"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?",,list(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 = tgui_alert(usr,"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?",,list(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 = input(user, "Choose your character's name:", "Character Preference") as text|null
- if(new_name)
- new_name = reject_bad_name(new_name, pref_species.allow_numbers_in_name)
- if(new_name)
- 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)
- age = max(min( round(text2num(new_age)), AGE_MAX),AGE_MIN)
-
- if("cycle_background")
- background = next_list_item(background, background_options)
-
- if("hair")
- var/new_hair = input(user, "Choose your character's hair colour:", "Character Preference","#"+hair_color) as color|null
- if(new_hair)
- hair_color = sanitize_hexcolor(new_hair)
-
- if("hair_style")
- var/new_hair_style
- if(gender == MALE)
- new_hair_style = input(user, "Choose your character's hair style:", "Character Preference") as null|anything in GLOB.hair_styles_male_list
- else if(gender == FEMALE)
- new_hair_style = input(user, "Choose your character's hair style:", "Character Preference") as null|anything in GLOB.hair_styles_female_list
- else
- new_hair_style = input(user, "Choose your character's hair style:", "Character Preference") as null|anything in GLOB.hair_styles_list
- if(new_hair_style)
- hair_style = new_hair_style
-
- if("next_hair_style")
- if (gender == MALE)
- hair_style = next_list_item(hair_style, GLOB.hair_styles_male_list)
- else if(gender == FEMALE)
- hair_style = next_list_item(hair_style, GLOB.hair_styles_female_list)
- else
- hair_style = next_list_item(hair_style, GLOB.hair_styles_list)
-
- if("previous_hair_style")
- if (gender == MALE)
- hair_style = previous_list_item(hair_style, GLOB.hair_styles_male_list)
- else if(gender == FEMALE)
- hair_style = previous_list_item(hair_style, GLOB.hair_styles_female_list)
- else
- hair_style = previous_list_item(hair_style, GLOB.hair_styles_list)
-
- if("facial")
- var/new_facial = input(user, "Choose your character's facial-hair colour:", "Character Preference","#"+facial_hair_color) as color|null
- if(new_facial)
- facial_hair_color = sanitize_hexcolor(new_facial)
-
- if("facial_hair_style")
- var/new_facial_hair_style
- if(gender == MALE)
- new_facial_hair_style = input(user, "Choose your character's facial-hair style:", "Character Preference") as null|anything in GLOB.facial_hair_styles_male_list
- else if(gender == FEMALE)
- new_facial_hair_style = input(user, "Choose your character's facial-hair style:", "Character Preference") as null|anything in GLOB.facial_hair_styles_female_list
- else
- new_facial_hair_style = input(user, "Choose your character's facial-hair style:", "Character Preference") as null|anything in GLOB.facial_hair_styles_list
- if(new_facial_hair_style)
- facial_hair_style = new_facial_hair_style
-
- if("next_facehair_style")
- if (gender == MALE)
- facial_hair_style = next_list_item(facial_hair_style, GLOB.facial_hair_styles_male_list)
- else if(gender == FEMALE)
- facial_hair_style = next_list_item(facial_hair_style, GLOB.facial_hair_styles_female_list)
- else
- facial_hair_style = next_list_item(facial_hair_style, GLOB.facial_hair_styles_list)
-
- if("previous_facehair_style")
- if (gender == MALE)
- facial_hair_style = previous_list_item(facial_hair_style, GLOB.facial_hair_styles_male_list)
- else if (gender == FEMALE)
- facial_hair_style = previous_list_item(facial_hair_style, GLOB.facial_hair_styles_female_list)
- else
- facial_hair_style = previous_list_item(facial_hair_style, GLOB.facial_hair_styles_list)
-
- if("hair_gradient")
- var/new_hair_gradient_color = input(user, "Choose your character's hair gradient colour:", "Character Preference","#"+features["gradientcolor"]) as color|null
- if(new_hair_gradient_color)
- features["gradientcolor"] = sanitize_hexcolor(new_hair_gradient_color)
-
- if("hair_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)
- features["gradientstyle"] = new_gradient_style
-
- if("next_hair_gradient_style")
- features["gradientstyle"] = next_list_item(features["gradientstyle"], GLOB.hair_gradients_list)
-
- if("previous_hair_gradient_style")
- features["gradientstyle"] = previous_list_item(features["gradientstyle"], GLOB.hair_gradients_list)
-
- if("underwear")
- var/new_underwear
- if(gender == MALE)
- new_underwear = input(user, "Choose your character's underwear:", "Character Preference") as null|anything in GLOB.underwear_m
- else if(gender == FEMALE)
- new_underwear = input(user, "Choose your character's underwear:", "Character Preference") as null|anything in GLOB.underwear_f
- else
- new_underwear = input(user, "Choose your character's underwear:", "Character Preference") as null|anything in GLOB.underwear_list
- if(new_underwear)
- underwear = new_underwear
-
- if("undershirt")
- var/new_undershirt
- if(gender == MALE)
- new_undershirt = input(user, "Choose your character's undershirt:", "Character Preference") as null|anything in GLOB.undershirt_m
- else if(gender == FEMALE)
- new_undershirt = input(user, "Choose your character's undershirt:", "Character Preference") as null|anything in GLOB.undershirt_f
- else
- new_undershirt = input(user, "Choose your character's undershirt:", "Character Preference") as null|anything in GLOB.undershirt_list
- if(new_undershirt)
- undershirt = new_undershirt
-
- if("socks")
- var/new_socks
- new_socks = input(user, "Choose your character's socks:", "Character Preference") as null|anything in GLOB.socks_list
- if(new_socks)
- socks = new_socks
-
- if(BODY_ZONE_PRECISE_EYES)
- var/new_eyes = input(user, "Choose your character's eye colour:", "Character Preference","#"+eye_color) as color|null
- if(new_eyes)
- eye_color = sanitize_hexcolor(new_eyes)
-
- if("species")
-
- var/result = input(user, "Select a species", "Species Selection") as null|anything in (is_mentor(user) ? (GLOB.roundstart_races + GLOB.mentor_races) : GLOB.roundstart_races)
-
- if(result)
- var/newtype = GLOB.species_list[result]
- pref_species = new newtype()
- //Now that we changed our species, we must verify that the mutant colour is still allowed.
- var/temp_hsv = RGBtoHSV(features["mcolor"])
- if(features["mcolor"] == "#000" || (!(MUTCOLORS_PARTSONLY in pref_species.species_traits) && ReadHSV(temp_hsv)[3] < ReadHSV("#3a3a3a")[3]))
- features["mcolor"] = pref_species.default_color
- var/CQ
- for(var/Q in all_quirks)
- var/quirk_type = SSquirks.quirks[Q]
- var/datum/quirk/quirk = new quirk_type(no_init = TRUE)
- CQ = quirk.check_quirk(src)
- if(CQ)
- all_quirks -= Q
- to_chat(user, span_danger(CQ))
- if(GetQuirkBalance() < 0)
- to_chat(user, span_danger("Your quirk balance is now negative, and you will need to re-balance it or all quirks will be disabled."))
-
- if("mcolor")
- var/new_mutantcolor = input(user, "Choose your character's alien/mutant color:", "Character Preference","#"+features["mcolor"]) as color|null
- if(new_mutantcolor)
- var/temp_hsv = RGBtoHSV(new_mutantcolor)
- if(new_mutantcolor == "#000000")
- features["mcolor"] = pref_species.default_color
- else if((MUTCOLORS_PARTSONLY in pref_species.species_traits) || ReadHSV(temp_hsv)[3] >= ReadHSV("#3a3a3a")[3]) // mutantcolors must be bright, but only if they affect the skin
- features["mcolor"] = sanitize_hexcolor(new_mutantcolor)
- else
- to_chat(user, span_danger("Invalid color. Your color is not bright enough."))
-
- if("ethcolor")
- var/new_etherealcolor = input(user, "Choose your ethereal color", "Character Preference") as null|anything in GLOB.color_list_ethereal
- if(new_etherealcolor)
- features["ethcolor"] = GLOB.color_list_ethereal[new_etherealcolor]
-
- if("pretcolor")
- var/new_preterniscolor = input(user, "Choose your preternis color", "Character Preference") as null|anything in GLOB.color_list_preternis
- if(new_preterniscolor)
- features["pretcolor"] = GLOB.color_list_preternis[new_preterniscolor]
-
- 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)
- features["tail_lizard"] = new_tail
-
- if("tail_polysmorph")
- var/new_tail
- new_tail = input(user, "Choose your character's tail:", "Character Preference") as null|anything in GLOB.tails_list_polysmorph
- if(new_tail)
- features["tail_polysmorph"] = 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)
- 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)
- 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)
- 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)
- 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)
- 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)
- 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)
- 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)
- 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)
- 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_list
- if(new_moth_wings)
- features["moth_wings"] = new_moth_wings
-
- if("teeth")
- var/new_teeth
- new_teeth = input(user, "Choose your character's teeth:", "Character Preference") as null|anything in GLOB.teeth_list
- if(new_teeth)
- features["teeth"] = new_teeth
-
- if("dome")
- var/new_dome
- new_dome = input(user, "Choose your character's dome:", "Character Preference") as null|anything in GLOB.dome_list
- if(new_dome)
- features["dome"] = new_dome
-
- if("dorsal_tubes")
- var/new_dorsal_tubes
- new_dorsal_tubes = input(user, "Choose if your character has dorsal tubes:", "Character Preference") as null|anything in GLOB.dorsal_tubes_list
- if(new_dorsal_tubes)
- features["dorsal_tubes"] = new_dorsal_tubes
-
- if("ethereal_mark")
- var/new_ethereal_mark
- new_ethereal_mark = input(user, "Choose if your character has a facial mark", "Character Preference") as null|anything in GLOB.ethereal_mark_list
- if(new_ethereal_mark)
- features["ethereal_mark"] = new_ethereal_mark
-
- if("pod_hair")
- var/new_pod_hair
- new_pod_hair = input(user, "Choose the style of your head vegitation", "Character Preference") as null|anything in GLOB.pod_hair_list
- if(new_pod_hair)
- features["pod_hair"] = new_pod_hair
- features["pod_flower"] = new_pod_hair
- if("pod_hair_color")
- var/new_hair = input(user, "Choose your character's \"hair\" colour:", "Character Preference","#"+hair_color) as color|null
- if(new_hair)
- var/temp_hsv = RGBtoHSV(new_hair)
- if(new_hair == "#000000")
- hair_color = pref_species.default_color
- to_chat(user, span_danger("Invalid \"hair\" color. Your color is not bright enough."))
- else if(ReadHSV(temp_hsv)[3] >= ReadHSV("#3a3a3a")[3]) // mutantcolors must be bright, but only if they affect the skin
- hair_color = sanitize_hexcolor(new_hair)
- else
- to_chat(user, span_danger("Invalid \"hair\" color. Your color is not bright enough."))
- if("pod_flower_color")
- var/new_facial = input(user, "Choose your character's head flower colour:", "Character Preference","#"+facial_hair_color) as color|null
- if(new_facial)
- var/temp_hsv = RGBtoHSV(new_facial)
- if(new_facial == "#000000")
- facial_hair_color = pref_species.default_color
- to_chat(user, span_danger("Invalid \"hair\" color. Your color is not bright enough."))
- else if(ReadHSV(temp_hsv)[3] >= ReadHSV("#3a3a3a")[3]) // mutantcolors must be bright, but only if they affect the skin
- facial_hair_color = sanitize_hexcolor(new_facial)
- else
- to_chat(user, span_danger("Invalid head flower color. Your color is not bright enough."))
- 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)
- 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)
- 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)
- features["ipc_chassis"] = new_ipc_chassis
-
- if("plasmaman_helmet")
- var/new_plasmaman_helmet
-
- new_plasmaman_helmet = input(user, "Choose your character's plasmaman helmet style:", "Character Preference") as null|anything in GLOB.plasmaman_helmet_list
- if(new_plasmaman_helmet)
- features["plasmaman_helmet"] = new_plasmaman_helmet
-
- 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)
- skin_tone = new_s_tone
-
- if("ooccolor")
- var/new_ooccolor = input(user, "Choose your OOC colour:", "Game Preference",ooccolor) as color|null
- if(new_ooccolor)
- ooccolor = new_ooccolor
-
- if("asaycolor")
- var/new_asaycolor = input(user, "Choose your ASAY color:", "Game Preference",asaycolor) as color|null
- if(new_asaycolor)
- asaycolor = 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)
- backbag = new_backbag
-
- if("suit")
- jumpsuit_style = jumpsuit_style == PREF_SUIT ? PREF_SKIRT : 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)
- uplink_spawn_loc = 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)
- 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)
- prefered_security_department = department
-
- if("eng_dept")
- var/department = input(user, "Choose your preferred engineering department:", "Engineering Departments") as null|anything in GLOB.engineering_depts_prefs
- if(department)
- prefered_engineering_department = department
-
- if("accent")
- var/aksent = input(user,"Choose your accent:","Available Accents") as null|anything in (assoc_list_strip_value(strings("accents.json", "accent_file_names", directory = "strings/accents")) + "None")
- if(aksent)
- if(aksent == "None")
- accent = initial(accent)
- else
- accent = aksent
- 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)
- 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 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_style")
- var/pickedPDAStyle = input(user, "Choose your PDA style.", "Character Preference", pda_style) as null|anything in GLOB.pda_styles
- if(pickedPDAStyle)
- pda_style = pickedPDAStyle
- if("pda_color")
- var/pickedPDAColor = input(user, "Choose your PDA Interface color.", "Character Preference",pda_color) as color|null
- if(pickedPDAColor)
- pda_color = pickedPDAColor
- if("pda_theme")
- var/pickedPDATheme = input(user, "Choose your PDA Interface theme.", "Character Preference", pda_theme) as null|anything in GLOB.pda_themes
- if(pickedPDATheme)
- pda_theme = pickedPDATheme
- if("id_in_pda")
- id_in_pda = !id_in_pda
- if("skillcape")
- var/list/selectablecapes = list()
- var/max_eligable = TRUE
- for(var/id in GLOB.skillcapes)
- var/datum/skillcape/A = GLOB.skillcapes[id]
- if(!A.job)
- continue
- if(user.client.prefs.exp[A.job] >= A.minutes)
- selectablecapes += A
- else
- max_eligable = FALSE
- if(max_eligable)
- selectablecapes += GLOB.skillcapes["max"]
-
- if(!selectablecapes.len)
- to_chat(user, "You have no availiable skillcapes!")
- return
- var/pickedskillcape = input(user, "Choose your Skillcape.", "Character Preference") as null|anything in (list("None") + selectablecapes)
- if(!pickedskillcape)
- return
- if(pickedskillcape == "None")
- skillcape_id = "None"
- else
- var/datum/skillcape/cape = pickedskillcape
- skillcape_id = cape.id
- if("flare")
- flare = !flare
- if("map")
- map = !map
- if("bar_choice")
- var/pickedbar = input(user, "Choose your bar.", "Character Preference", bar_choice) as null|anything in (GLOB.potential_box_bars|"Random")
- if(!pickedbar)
- return
- bar_choice = pickedbar
- if ("max_chat_length")
- var/desiredlength = input(user, "Choose the max character length of shown Runechat messages. Valid range is 1 to [CHAT_MESSAGE_MAX_LENGTH] (default: [initial(max_chat_length)]))", "Character Preference", max_chat_length) as null|num
- if (!isnull(desiredlength))
- max_chat_length = clamp(desiredlength, 1, CHAT_MESSAGE_MAX_LENGTH)
- if("alternative_announcers")
- disable_alternative_announcers = !disable_alternative_announcers
- if("balloon_alerts")
- disable_balloon_alerts = !disable_balloon_alerts
- else
- switch(href_list["preference"])
- if("publicity")
- if(unlock_content)
- toggles ^= MEMBER_PUBLIC
- if("gender")
- var/pickedGender = input(user, "Choose your gender.", "Character Preference", gender) as null|anything in friendlyGenders
- if(pickedGender && friendlyGenders[pickedGender] != gender)
- gender = friendlyGenders[pickedGender]
- underwear = random_underwear(gender)
- undershirt = random_undershirt(gender)
- socks = random_socks()
- facial_hair_style = random_facial_hair_style(gender)
- hair_style = random_hair_style(gender)
-
- if("hotkeys")
- hotkeys = !hotkeys
- if(hotkeys)
- winset(user, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED]")
- else
- winset(user, null, "input.focus=true input.background-color=[COLOR_INPUT_DISABLED]")
-
- 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")
- ShowChoices(user)
- return
-
- var/clear_key = text2num(href_list["clear_key"])
- var/old_key = href_list["old_key"]
- if(clear_key)
- if(key_bindings[old_key])
- key_bindings[old_key] -= kb_name
- LAZYADD(key_bindings["Unbound"], kb_name)
- if(!length(key_bindings[old_key]))
- key_bindings -= old_key
- user << browse(null, "window=capturekeypress")
- user.client.set_macros()
- save_preferences()
- ShowChoices(user)
- return
-
- var/new_key = uppertext(href_list["key"])
- var/AltMod = text2num(href_list["alt"]) ? "Alt" : ""
- var/CtrlMod = text2num(href_list["ctrl"]) ? "Ctrl" : ""
- var/ShiftMod = text2num(href_list["shift"]) ? "Shift" : ""
- var/numpad = text2num(href_list["numpad"]) ? "Numpad" : ""
- // var/key_code = text2num(href_list["key_code"])
-
- if(GLOB._kbMap[new_key])
- new_key = GLOB._kbMap[new_key]
-
- var/full_key
- switch(new_key)
- if("Alt")
- full_key = "[new_key][CtrlMod][ShiftMod]"
- if("Ctrl")
- full_key = "[AltMod][new_key][ShiftMod]"
- if("Shift")
- full_key = "[AltMod][CtrlMod][new_key]"
- else
- full_key = "[AltMod][CtrlMod][ShiftMod][numpad][new_key]"
- if(kb_name in key_bindings[full_key]) //We pressed the same key combination that was already bound here, so let's remove to re-add and re-sort.
- key_bindings[full_key] -= kb_name
- if(key_bindings[old_key])
- key_bindings[old_key] -= kb_name
- if(!length(key_bindings[old_key]))
- key_bindings -= old_key
- key_bindings[full_key] += list(kb_name)
- key_bindings[full_key] = sortList(key_bindings[full_key])
-
- user << browse(null, "window=capturekeypress")
- user.client.set_macros()
- save_preferences()
-
- if("keybindings_reset")
- var/choice = tgalert(user, "Would you prefer 'hotkey' or 'classic' defaults?", "Setup keybindings", "Hotkey", "Classic", "Cancel")
- if(choice == "Cancel")
- ShowChoices(user)
- return
- hotkeys = (choice == "Hotkey")
- key_bindings = (hotkeys) ? deepCopyList(GLOB.hotkey_keybinding_list_by_key) : deepCopyList(GLOB.classic_keybinding_list_by_key)
- user.client.set_macros()
- if("chat_on_map")
- chat_on_map = !chat_on_map
- if("see_chat_non_mob")
- see_chat_non_mob = !see_chat_non_mob
- if("see_rc_emotes")
- see_rc_emotes = !see_rc_emotes
- if("action_buttons")
- buttons_locked = !buttons_locked
- if("tgui_fancy")
- tgui_fancy = !tgui_fancy
- if("tgui_lock")
- tgui_lock = !tgui_lock
- if("winflash")
- windowflashing = !windowflashing
-
- //here lies the badmins
- if("hear_adminhelps")
- user.client.toggleadminhelpsound()
- if("hear_prayers")
- user.client.toggle_prayer_sound()
- if("announce_login")
- user.client.toggleannouncelogin()
- if("combohud_lighting")
- toggles ^= COMBOHUD_LIGHTING
- if("toggle_dead_chat")
- user.client.deadchat()
- if("toggle_radio_chatter")
- user.client.toggle_hear_radio()
- if("toggle_split_admin_tabs")
- extra_toggles ^= SPLIT_ADMIN_TABS
- if("toggle_fast_mc_refresh")
- extra_toggles ^= FAST_MC_REFRESH
- if("toggle_prayers")
- user.client.toggleprayers()
- if("toggle_deadmin_always")
- toggles ^= DEADMIN_ALWAYS
- if("toggle_deadmin_antag")
- toggles ^= DEADMIN_ANTAGONIST
- if("toggle_deadmin_head")
- toggles ^= DEADMIN_POSITION_HEAD
- if("toggle_deadmin_security")
- toggles ^= DEADMIN_POSITION_SECURITY
- if("toggle_deadmin_silicon")
- toggles ^= DEADMIN_POSITION_SILICON
- if("toggle_deadmin_critical")
- toggles ^= DEADMIN_POSITION_CRITICAL
-
-
- if("be_special")
- var/be_special_type = href_list["be_special_type"]
- if(be_special_type in be_special)
- be_special -= be_special_type
- else
- be_special += be_special_type
-
- if("name")
- be_random_name = !be_random_name
-
- if("all")
- be_random_body = !be_random_body
-
- if("persistent_scars")
- persistent_scars = !persistent_scars
-
- if("clear_scars")
- var/path = "data/player_saves/[user.ckey[1]]/[user.ckey]/scars.sav"
- fdel(path)
- to_chat(user, span_notice("All scar slots cleared."))
-
- if("hear_midis")
- toggles ^= SOUND_MIDI
-
- if("lobby_music")
- toggles ^= SOUND_LOBBY
- if((toggles & SOUND_LOBBY) && user.client && isnewplayer(user))
- user.client.playtitlemusic()
- else
- user.stop_sound_channel(CHANNEL_LOBBYMUSIC)
-
- 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("income_pings")
- chat_toggles ^= CHAT_BANKCARD
-
- if("pull_requests")
- chat_toggles ^= CHAT_PULLR
-
- if("allow_midround_antag")
- toggles ^= MIDROUND_ANTAG
-
- 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")
- ambientocclusion = !ambientocclusion
- if(parent && parent.screen && parent.screen.len)
- var/atom/movable/screen/plane_master/game_world/PM = locate(/atom/movable/screen/plane_master/game_world) in parent.screen
- PM.backdrop(parent.mob)
-
- if("auto_fit_viewport")
- auto_fit_viewport = !auto_fit_viewport
- if(auto_fit_viewport && parent)
- parent.fit_viewport()
-
- if("widescreenpref")
- widescreenpref = !widescreenpref
- user.client.view_size.setDefault(getScreenSize(widescreenpref))
-
- 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.apply() //Let's winset() it so it actually works
-
- 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()
- save_character()
-
- if("load")
- load_preferences()
- load_character()
-
- if("changeslot")
- if(!load_character(text2num(href_list["num"])))
- random_character()
- real_name = random_unique_name(gender)
- save_character()
-
- if("tab")
- if (href_list["tab"])
- current_tab = text2num(href_list["tab"])
-
- if("mood")
- yogtoggles ^= PREF_MOOD
-
- if("moodtailwagging")
- mood_tail_wagging = !mood_tail_wagging
- // yogs end
-
- ShowChoices(user)
- return 1
-
-/datum/preferences/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)
- random_character(gender)
-
- if(roundstart_checks)
- if(CONFIG_GET(flag/humans_need_surnames) && (pref_species.id == "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.facial_hair_color = facial_hair_color
- character.grad_color = features["gradientcolor"]
-
- character.skin_tone = skin_tone
- character.hair_style = hair_style
- character.facial_hair_style = facial_hair_style
- character.grad_style = features["gradientstyle"]
- character.underwear = underwear
- character.undershirt = undershirt
- character.socks = socks
-
- character.backbag = backbag
-
- character.jumpsuit_style = jumpsuit_style
- character.id_in_pda = id_in_pda
-
- var/datum/species/chosen_species
- chosen_species = pref_species.type
- if(roundstart_checks && !(pref_species.id in GLOB.roundstart_races) && (!(pref_species.id in GLOB.mentor_races) && !is_mentor(character)) && !(pref_species.id in (CONFIG_GET(keyed_list/roundstart_no_hard_check))))
- chosen_species = /datum/species/human
- pref_species = new /datum/species/human
- save_character()
-
- character.dna.features = features.Copy()
- character.set_species(chosen_species, icon_update = FALSE, pref_load = TRUE)
- character.dna.real_name = character.real_name
+/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.savefile_identifier != PREFERENCE_CHARACTER)
+ continue
- if("tail_lizard" in pref_species.default_features)
- character.dna.species.mutant_bodyparts |= "tail_lizard"
+ // Dont apply if it's unique and we can't customize it
+ // This fixes pod hair color overwriting human hair color
+ if (preference.unique && !preference.can_apply(src))
+ continue
- if("tail_polysmorph" in pref_species.default_features)
- character.dna.species.mutant_bodyparts |= "tail_polysmorph"
+ preference.apply_to_human(character, read_preference(preference.type))
+
+ character.dna.real_name = character.real_name
if(icon_updates)
character.icon_render_key = null //turns out if you don't set this to null update_body_parts does nothing, since it assumes the operation was cached
@@ -2370,39 +534,39 @@ GLOBAL_LIST_EMPTY(preferences_datums)
character.update_hair()
character.update_body_parts()
-/datum/preferences/proc/get_default_name(name_id)
- switch(name_id)
- if("human")
- return random_unique_name()
- if("ai")
- return pick(GLOB.ai_names)
- if("cyborg")
- return DEFAULT_CYBORG_NAME
- if("clown")
- return pick(GLOB.clown_names)
- if("mime")
- return pick(GLOB.mime_names)
- if("religion")
- return DEFAULT_RELIGION
- if("deity")
- return DEFAULT_DEITY
- return random_unique_name()
-
-/datum/preferences/proc/ask_for_custom_name(mob/user,name_id)
- var/namedata = GLOB.preferences_custom_names[name_id]
- if(!namedata)
- return
+/// 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()
- var/raw_name = input(user, "Choose your character's [namedata["qdesc"]]:","Character Preference") as text|null
- if(!raw_name)
- if(namedata["allow_null"])
- 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
- custom_names[name_id] = sanitized_name
+ for (var/action in key_bindings)
+ for (var/key in key_bindings[action])
+ LAZYADD(output[key], action)
+
+ return output
+
+/// 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
+
+
+// yogs procs
+
+/datum/preferences/proc/GetPlayerAltTitle(datum/job/job)
+ return player_alt_titles.Find(job.title) > 0 \
+ ? player_alt_titles[job.title] \
+ : job.title
+
+/datum/preferences/proc/SetPlayerAltTitle(datum/job/job, new_title)
+ // remove existing entry
+ if(player_alt_titles.Find(job.title))
+ player_alt_titles -= job.title
+ // add one if it's not default
+ if(job.title != new_title)
+ player_alt_titles[job.title] = new_title
diff --git a/code/modules/client/preferences/README.md b/code/modules/client/preferences/README.md
new file mode 100644
index 000000000000..45c62cc4198d
--- /dev/null
+++ b/code/modules/client/preferences/README.md
@@ -0,0 +1,419 @@
+# Preferences (by Mothblocks)
+
+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.
+
+## 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. `savefile_key` - The value which will be saved in the savefile. This will also be the identifier for tgui.
+3. `savefile_identifier` - 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.
+
+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 savefile_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
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_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
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_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
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_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, FeatureDropdownInput } from "../base";
+export const favorite_drink: FeatureChoiced = {
+ name: "Favorite drink",
+ component: FeatureDropdownInput,
+};
+```
+
+This will create a dropdown input for your preference.
+
+### 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
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_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
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_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
+ savefile_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
+```
+
+## 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 savefile_identifier == 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 savefile_identifier == 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_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"
+```
+
+
+
+> 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 savefile_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/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
+
+In order to make an antagonist selectable, you must do a few things:
+
+1. Your antagonist needs an icon.
+2. Your antagonist must be in a Dynamic ruleset. The ruleset must specify the antagonist as its `antag_flag`.
+3. Your antagonist needs a file in `tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/filename.ts`. This file name MUST be the `antag_flag` of your ruleset, with nothing but letters remaining (e.g. "Nuclear Operative" -> `nuclearoperative`).
+4. Add it to `special_roles`.
+
+## Creating icons
+
+If you are satisfied with your icon just being a dude with some clothes, then you can specify `preview_outfit` in your `/datum/antagonist`.
+
+Space Ninja, for example, looks like:
+
+```dm
+/datum/antagonist/ninja
+ preview_outift = /datum/outfit/ninja
+```
+
+However, if you want to get creative, you can override `/get_preview_icon()`. This proc should return an icon of size `ANTAGONIST_PREVIEW_ICON_SIZE`x`ANTAGONIST_PREVIEW_ICON_SIZE`.
+
+There are some helper procs you can use as well. `render_preview_outfit(outfit_type)` will take an outfit and give you an icon of someone wearing those clothes. `finish_preview_outfit` will, given an icon, resize it appropriately and zoom in on the head. Note that this will look bad on anything that isn't a human, so if you have a non-human antagonist (such as sentient disease), just run `icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)`.
+
+For inspiration, here is changeling's:
+
+```dm
+/datum/antagonist/changeling/get_preview_icon()
+ var/icon/final_icon = render_preview_outfit(/datum/outfit/changeling)
+ 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)
+```
+
+...which creates:
+
+
+
+## Creating the tgui representation
+
+In the `.ts` file you created earlier, you must now give the information of your antagonist. For reference, this is the changeling's:
+
+```ts
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+const Changeling: Antagonist = {
+ key: "changeling", // This must be the same as your filename
+ name: "Changeling",
+ description: [
+ multiline`
+ A highly intelligent alien predator that is capable of altering their
+ shape to flawlessly resemble a human.
+ `,
+ multiline`
+ Transform yourself or others into different identities, and buy from an
+ arsenal of biological weaponry with the DNA you collect.
+ `,
+ ],
+ category: Category.Roundstart, // Category.Roundstart, Category.Midround, or Category.Latejoin
+};
+export default Changeling;
+```
+
+## Readying the Dynamic ruleset
+
+You already need to create a Dynamic ruleset, so in order to get your antagonist recognized, you just need to specify `antag_flag`. This must be unique per ruleset.
+
+Two other values to note are `antag_flag_override` and `antag_preference`.
+
+`antag_flag_override` exists for cases where you want the banned antagonist to be separate from `antag_flag`. As an example: roundstart, midround, and latejoin traitors have separate `antag_flag`, but all have `antag_flag_override = ROLE_TRAITOR`. This is because admins want to ban a player from Traitor altogether, not specific rulesets.
+
+If `antag_preference` is set, it will refer to that preference instead of `antag_flag`. This is used for clown operatives, which we want to be on the same preference as standard nuke ops, but must specify a unique `antag_flag` for.
+
+## Updating special_roles
+
+In `code/__DEFINES/role_preferences.dm` (the same place you'll need to make your ROLE_\* defined), simply add your antagonist to the `special_roles` assoc list. The key is your ROLE, the value is the number of days since your first game in order to play as that antagonist.
diff --git a/code/modules/client/preferences/_preference.dm b/code/modules/client/preferences/_preference.dm
new file mode 100644
index 000000000000..b90084afccc7
--- /dev/null
+++ b/code/modules/client/preferences/_preference.dm
@@ -0,0 +1,565 @@
+// 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 names are decided, needed for proper randomization.
+#define PREFERENCE_PRIORITY_NAMES 4
+
+/// 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 choiced preferences, this key will be used to lock choices to a specific ckey.
+#define CHOICED_PREFERENCE_KEY_LOCKED "key_locked"
+
+/// 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 `savefile_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.savefile_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 savefile to use.
+ /// This is also sent to the UI.
+ /// Once you pick this, don't change it.
+ var/savefile_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 savefile should this preference be read from?
+ /// Valid values are PREFERENCE_CHARACTER and PREFERENCE_PLAYER.
+ /// See the documentation in [code/__DEFINES/preferences.dm].
+ var/savefile_identifier
+
+ /// 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 the target should be checked upon applying the preference
+ /// For example, this is used for for podperson hair_color since it overwrites human hair_color
+ var/unique = 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 savefile, 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 savefile.
+/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 savefile.
+/// Either this or create_informed_default_value must be overriden by subtypes.
+/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 savefile.
+/// 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 savefile_identifier == PREFERENCE_CHARACTER && can_randomize
+
+/// Given a savefile, return either the saved data or an acceptable default.
+/// This will write to the savefile if a value was not found with the new value.
+/datum/preference/proc/read(savefile/savefile, datum/preferences/preferences)
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ var/value
+
+ if (!isnull(savefile))
+ READ_FILE(savefile[savefile_key], value)
+
+ if (isnull(value))
+ return null
+ else
+ return deserialize(value, preferences)
+
+/// Given a savefile, writes the inputted value.
+/// Returns TRUE for a successful application.
+/// Return FALSE if it is invalid.
+/datum/preference/proc/write(savefile/savefile, value)
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ if (!is_valid(value))
+ return FALSE
+
+ if (!isnull(savefile))
+ WRITE_FILE(savefile[savefile_key], serialize(value))
+
+ return TRUE
+
+/// Apply this preference onto the given client.
+/// Called when the savefile_identifier == PREFERENCE_PLAYER.
+/datum/preference/proc/apply_to_client(client/client, value)
+ //SHOULD_NOT_SLEEP(TRUE) // Broken because we have a bunch of sleeping shit
+ 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 savefile_identifier == PREFERENCE_CHARACTER.
+/datum/preference/proc/apply_to_human(mob/living/carbon/human/target, value)
+ //SHOULD_NOT_SLEEP(TRUE) // Broken because we have a bunch of sleeping shit
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("`apply_to_human()` was not implemented for [type]!")
+
+/// Returns which savefile to use for a given savefile identifier
+/datum/preferences/proc/get_savefile_for_savefile_identifier(savefile_identifier)
+ RETURN_TYPE(/savefile)
+
+ if (!parent)
+ return null
+
+ // Both of these will cache savefiles, but only for a tick.
+ // This is because storing a savefile will lock it, causing later issues down the line.
+ // Do not change them to addtimer, since the timer SS might not be running at this time.
+
+ switch (savefile_identifier)
+ if (PREFERENCE_CHARACTER)
+ if (!character_savefile)
+ character_savefile = new /savefile(path)
+ character_savefile.cd = "/character[default_slot]"
+ spawn (1)
+ character_savefile = null
+ return character_savefile
+ if (PREFERENCE_PLAYER)
+ if (!game_savefile)
+ game_savefile = new /savefile(path)
+ game_savefile.cd = "/"
+ spawn (1)
+ game_savefile = null
+ return game_savefile
+ else
+ CRASH("Unknown savefile identifier [savefile_identifier]")
+
+/// Read a /datum/preference type and return its value.
+/// This will write to the savefile if a value was not found with the new value.
+/datum/preferences/proc/read_preference(preference_type)
+ var/datum/preference/preference_entry = GLOB.preference_entries[preference_type]
+ 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_type]` is invalid! [extra_info]")
+
+ if (preference_type in value_cache)
+ return value_cache[preference_type]
+
+ var/value = preference_entry.read(get_savefile_for_savefile_identifier(preference_entry.savefile_identifier), src)
+ if (isnull(value))
+ value = preference_entry.create_informed_default_value(src)
+ if (write_preference(preference_entry, value))
+ return value
+ else
+ CRASH("Couldn't write the default value for [preference_type] (received [value])")
+ value_cache[preference_type] = value
+ return value
+
+/// 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)
+ var/savefile = get_savefile_for_savefile_identifier(preference.savefile_identifier)
+ var/new_value = preference.deserialize(preference_value, src)
+ var/success = preference.write(savefile, new_value)
+ if (success)
+ value_cache[preference.type] = new_value
+ return success
+
+/// Will perform an update on the preference, but not write to the savefile.
+/// This will, for instance, update the character preference view.
+/// Performs sanity checks.
+/datum/preferences/proc/update_preference(datum/preference/preference, preference_value)
+ if (!preference.is_accessible(src))
+ return FALSE
+
+ var/new_value = preference.deserialize(preference_value, src)
+ var/success = preference.write(null, new_value)
+
+ if (!success)
+ return FALSE
+
+ recently_updated_keys |= preference.type
+ value_cache[preference.type] = new_value
+
+ if (preference.savefile_identifier == PREFERENCE_PLAYER)
+ preference.apply_to_client_updated(parent, read_preference(preference.type))
+ else
+ character_preview_view?.update_body()
+
+ 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)
+ SHOULD_CALL_PARENT(TRUE)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ if (!isnull(relevant_mutant_bodypart) || !isnull(relevant_species_trait))
+ var/species_type = preferences.read_preference(/datum/preference/choiced/species)
+
+ var/datum/species/species = new species_type
+ if (!(savefile_key in species.get_features()))
+ return FALSE
+
+ if (!should_show_on_page(preferences.current_window))
+ return FALSE
+
+ return TRUE
+
+/// Return whether or not we can apply this preference
+/datum/preference/proc/can_apply(datum/preferences/preferences)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ if (!isnull(relevant_mutant_bodypart) || !isnull(relevant_species_trait))
+ var/species_type = preferences.read_preference(/datum/preference/choiced/species)
+
+ var/datum/species/species = new species_type
+ if (!(savefile_key in species.get_features()))
+ 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 = savefile_identifier == 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
+
+ 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
+
+ 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_color(input)
+
+/datum/preference/color/create_default_value()
+ return random_color()
+
+/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]
+ if(sprite_accessory.locked)
+ continue
+
+ 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 >= minimum && value <= maximum
+
+/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)
+ return !!input
+
+/datum/preference/toggle/is_valid(value)
+ return value == TRUE || value == FALSE
diff --git a/code/modules/client/preferences/accent.dm b/code/modules/client/preferences/accent.dm
new file mode 100644
index 000000000000..7b23d66de44c
--- /dev/null
+++ b/code/modules/client/preferences/accent.dm
@@ -0,0 +1,19 @@
+/datum/preference/choiced/accent
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "accent"
+ savefile_identifier = PREFERENCE_CHARACTER
+
+/datum/preference/choiced/accent/create_default_value()
+ return ACCENT_NONE
+
+/datum/preference/choiced/accent/init_possible_values()
+ return GLOB.accents_names
+
+/datum/preference/choiced/accent/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.accents_names))
+ return ACCENT_NONE
+
+ return ..(input, preferences)
+
+/datum/preference/choiced/accent/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/admin.dm b/code/modules/client/preferences/admin.dm
new file mode 100644
index 000000000000..7eaf041d4450
--- /dev/null
+++ b/code/modules/client/preferences/admin.dm
@@ -0,0 +1,14 @@
+/datum/preference/color/asay_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "asaycolor"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/color/asay_color/create_default_value()
+ return DEFAULT_ASAY_COLOR
+
+/datum/preference/color/asay_color/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return is_admin(preferences.parent) && CONFIG_GET(flag/allow_admin_asaycolor)
+
diff --git a/code/modules/client/preferences/age.dm b/code/modules/client/preferences/age.dm
new file mode 100644
index 000000000000..cad9786ce1fe
--- /dev/null
+++ b/code/modules/client/preferences/age.dm
@@ -0,0 +1,10 @@
+/datum/preference/numeric/age
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "age"
+ savefile_identifier = 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/ai_core_display.dm b/code/modules/client/preferences/ai_core_display.dm
new file mode 100644
index 000000000000..08272254ddab
--- /dev/null
+++ b/code/modules/client/preferences/ai_core_display.dm
@@ -0,0 +1,28 @@
+/// What to show on the AI screen
+/datum/preference/choiced/ai_core_display
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "preferred_ai_core_display"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/choiced/ai_core_display/create_default_value()
+ return "Random"
+
+/datum/preference/choiced/ai_core_display/init_possible_values()
+ return GLOB.ai_core_display_screens - "Portrait"
+
+/datum/preference/choiced/ai_core_display/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.ai_core_display_screens))
+ return "Random"
+
+ return ..(input, preferences)
+
+/datum/preference/choiced/ai_core_display/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ // Job needs to be medium or high for the preference to show up
+ return preferences.job_preferences["AI"] >= JP_MEDIUM
+
+/datum/preference/choiced/ai_core_display/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/ambient_occlusion.dm b/code/modules/client/preferences/ambient_occlusion.dm
new file mode 100644
index 000000000000..a81efca00bd8
--- /dev/null
+++ b/code/modules/client/preferences/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
+ savefile_key = "ambientocclusion"
+ savefile_identifier = 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/announcer.dm b/code/modules/client/preferences/announcer.dm
new file mode 100644
index 000000000000..92fbb887ecc8
--- /dev/null
+++ b/code/modules/client/preferences/announcer.dm
@@ -0,0 +1,5 @@
+/datum/preference/toggle/disable_alternative_announcers
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "disable_alternative_announcers"
+ savefile_identifier = PREFERENCE_PLAYER
+ default_value = FALSE
diff --git a/code/modules/client/preferences/assets.dm b/code/modules/client/preferences/assets.dm
new file mode 100644
index 000000000000..135cec77eac9
--- /dev/null
+++ b/code/modules/client/preferences/assets.dm
@@ -0,0 +1,66 @@
+/// Assets generated from `/datum/preference` icons
+/datum/asset/spritesheet/preferences
+ name = "preferences"
+ early = TRUE
+ cross_round_cachable = TRUE
+ load_immediately = TRUE
+
+/datum/asset/spritesheet/preferences/create_spritesheets()
+ var/list/to_insert = list()
+
+ 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
+
+ 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]).")
+
+ to_insert[preference.get_spritesheet_key(preference_value)] = list(icon, icon_state)
+
+ for (var/spritesheet_key in to_insert)
+ var/list/inserting = to_insert[spritesheet_key]
+ Insert(spritesheet_key, inserting[1], inserting[2])
+
+/// Returns the key that will be used in the spritesheet for a given value.
+/datum/preference/proc/get_spritesheet_key(value)
+ return "[savefile_key]___[sanitize_css_class_name(value)]"
+
+/// 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.savefile_key] = data
+
+ return preference_data
diff --git a/code/modules/client/preferences/auto_fit_viewport.dm b/code/modules/client/preferences/auto_fit_viewport.dm
new file mode 100644
index 000000000000..3550af054549
--- /dev/null
+++ b/code/modules/client/preferences/auto_fit_viewport.dm
@@ -0,0 +1,7 @@
+/datum/preference/toggle/auto_fit_viewport
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "auto_fit_viewport"
+ savefile_identifier = 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/balloon_alerts.dm b/code/modules/client/preferences/balloon_alerts.dm
new file mode 100644
index 000000000000..b078d951603e
--- /dev/null
+++ b/code/modules/client/preferences/balloon_alerts.dm
@@ -0,0 +1,5 @@
+/datum/preference/toggle/disable_balloon_alerts
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "disable_balloon_alerts"
+ savefile_identifier = PREFERENCE_PLAYER
+ default_value = FALSE
diff --git a/code/modules/client/preferences/bar_choice.dm b/code/modules/client/preferences/bar_choice.dm
new file mode 100644
index 000000000000..0721e77092fc
--- /dev/null
+++ b/code/modules/client/preferences/bar_choice.dm
@@ -0,0 +1,23 @@
+/// Which bar to spawn on boxstation
+/datum/preference/choiced/bar_choice
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "bar_choice"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/choiced/bar_choice/create_default_value()
+ return "Random"
+
+/datum/preference/choiced/bar_choice/init_possible_values()
+ return GLOB.potential_box_bars + "Random"
+
+/datum/preference/choiced/bar_choice/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ // Job needs to be medium or high for the preference to show up
+ return preferences.job_preferences["Bartender"] >= JP_MEDIUM
+
+/datum/preference/choiced/bar_choice/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
diff --git a/code/modules/client/preferences/buttons_locked.dm b/code/modules/client/preferences/buttons_locked.dm
new file mode 100644
index 000000000000..b4f54ff10f8f
--- /dev/null
+++ b/code/modules/client/preferences/buttons_locked.dm
@@ -0,0 +1,5 @@
+/datum/preference/toggle/buttons_locked
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "buttons_locked"
+ savefile_identifier = PREFERENCE_PLAYER
+ default_value = FALSE
diff --git a/code/modules/client/preferences/clothing.dm b/code/modules/client/preferences/clothing.dm
new file mode 100644
index 000000000000..4b85cedd1f63
--- /dev/null
+++ b/code/modules/client/preferences/clothing.dm
@@ -0,0 +1,145 @@
+/proc/generate_values_for_underwear(icon_file, list/accessory_list, list/icons)
+ var/icon/lower_half = icon('icons/blanks/32x32.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(icon_file, accessory.icon_state)
+ 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
+ savefile_key = "backbag"
+ savefile_identifier = 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 atmos, and so engi backpacks you get.
+ values[DBACKPACK] = /obj/item/storage/backpack/industrial
+ values[DSATCHEL] = /obj/item/storage/backpack/satchel/eng
+ values[DDUFFELBAG] = /obj/item/storage/backpack/duffelbag/engineering
+
+ return values
+
+/datum/preference/choiced/backpack/create_default_value()
+ return GBACKPACK
+
+/datum/preference/choiced/backpack/apply_to_human(mob/living/carbon/human/target, value)
+ target.backbag = value
+
+/// Jumpsuit preference
+/datum/preference/choiced/jumpsuit
+ savefile_key = "jumpsuit_style"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Jumpsuit"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/jumpsuit/init_possible_values()
+ var/list/values = list()
+
+ values[PREF_SUIT] = /obj/item/clothing/under/color/grey
+ values[PREF_SKIRT] = /obj/item/clothing/under/skirt/color/grey
+
+ return values
+
+/datum/preference/choiced/jumpsuit/apply_to_human(mob/living/carbon/human/target, value)
+ target.jumpsuit_style = value
+
+/// Socks preference
+/datum/preference/choiced/socks
+ savefile_key = "socks"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Socks"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/socks/init_possible_values()
+ return generate_values_for_underwear('icons/mob/clothing/sprite_accessories/socks.dmi', 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
+ savefile_key = "undershirt"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Undershirt"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/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/sprite_accessories/undershirt.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
+ savefile_key = "underwear"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Underwear"
+ category = PREFERENCE_CATEGORY_CLOTHING
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/underwear/init_possible_values()
+ return generate_values_for_underwear('icons/mob/clothing/sprite_accessories/underwear.dmi', GLOB.underwear_list, list("human_chest_m", "human_r_leg", "human_l_leg"))
+
+/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)
+ if (!..(preferences))
+ return FALSE
+
+ var/species_type = preferences.read_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/credits.dm b/code/modules/client/preferences/credits.dm
new file mode 100644
index 000000000000..f238c321d63b
--- /dev/null
+++ b/code/modules/client/preferences/credits.dm
@@ -0,0 +1,4 @@
+/datum/preference/toggle/show_credits
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "show_credits"
+ savefile_identifier = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/donor.dm b/code/modules/client/preferences/donor.dm
new file mode 100644
index 000000000000..53f396e4322b
--- /dev/null
+++ b/code/modules/client/preferences/donor.dm
@@ -0,0 +1,144 @@
+/datum/preference/choiced/donor_hat
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "donor_hat"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/donor_hat/create_default_value()
+ return "None"
+
+/datum/preference/choiced/donor_hat/init_possible_values()
+ var/list/values = list()
+
+ values += "None"
+
+ for(var/datum/donator_gear/S as anything in GLOB.donator_gear.donor_items)
+ if(S.slot != SLOT_HEAD)
+ continue
+
+ values += S.name
+
+ return values
+
+/datum/preference/choiced/donor_hat/compile_constant_data()
+ var/list/data = ..()
+
+ var/list/key_locked = list()
+
+ for(var/datum/donator_gear/S as anything in GLOB.donator_gear.donor_items)
+ if(S.slot != SLOT_HEAD && !S.plush)
+ continue
+
+ if (!S.ckey)
+ continue
+
+ key_locked[S.name] = lowertext(S.ckey)
+
+ data[CHOICED_PREFERENCE_KEY_LOCKED] = key_locked
+
+ return data
+
+
+/datum/preference/choiced/donor_item
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "donor_item"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/donor_item/create_default_value()
+ return "None"
+
+/datum/preference/choiced/donor_item/init_possible_values()
+ var/list/values = list()
+
+ values += "None"
+
+ for(var/datum/donator_gear/S as anything in GLOB.donator_gear.donor_items)
+ if(S.slot == SLOT_HEAD && !S.plush)
+ continue
+
+ values += S.name
+
+ return values
+
+/datum/preference/choiced/donor_item/compile_constant_data()
+ var/list/data = ..()
+
+ var/list/key_locked = list()
+
+ for(var/datum/donator_gear/S as anything in GLOB.donator_gear.donor_items)
+ if(S.slot == SLOT_HEAD)
+ continue
+
+ if (!S.ckey)
+ continue
+
+ key_locked[S.name] = lowertext(S.ckey)
+
+ data[CHOICED_PREFERENCE_KEY_LOCKED] = key_locked
+
+ return data
+
+
+/datum/preference/choiced/donor_plush
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "donor_plush"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/donor_plush/create_default_value()
+ return "None"
+
+/datum/preference/choiced/donor_plush/init_possible_values()
+ var/list/values = list()
+
+ values += "None"
+
+ for(var/datum/donator_gear/S as anything in GLOB.donator_gear.donor_items)
+ if(S.plush)
+ values += S.name
+
+ return values
+
+/datum/preference/choiced/donor_plush/compile_constant_data()
+ var/list/data = ..()
+
+ var/list/key_locked = list()
+
+ for(var/datum/donator_gear/S as anything in GLOB.donator_gear.donor_items)
+ if(S.slot == SLOT_HEAD)
+ continue
+
+ if(!S.plush)
+ continue
+
+ if (!S.ckey)
+ continue
+
+ key_locked[S.name] = lowertext(S.ckey)
+
+ data[CHOICED_PREFERENCE_KEY_LOCKED] = key_locked
+
+ return data
+
+
+
+/datum/preference/toggle/borg_hat
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "borg_hat"
+ savefile_identifier = PREFERENCE_PLAYER
+
+
+/datum/preference/choiced/donor_pda
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "donor_pda"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/donor_pda/create_default_value()
+ return PDA_COLOR_NORMAL
+
+/datum/preference/choiced/donor_pda/init_possible_values()
+ return GLOB.donor_pdas
+
+
+/datum/preference/toggle/purrbation
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "purrbation"
+ savefile_identifier = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/engineering_department.dm b/code/modules/client/preferences/engineering_department.dm
new file mode 100644
index 000000000000..08ea805bdc16
--- /dev/null
+++ b/code/modules/client/preferences/engineering_department.dm
@@ -0,0 +1,29 @@
+/// Which department to put station engineers in
+/datum/preference/choiced/engineering_department
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "prefered_engineering_department"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/choiced/engineering_department/create_default_value()
+ return ENG_DEPT_NONE
+
+/datum/preference/choiced/engineering_department/init_possible_values()
+ return GLOB.engineering_depts_prefs
+
+/datum/preference/choiced/engineering_department/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.engineering_depts_prefs))
+ return ENG_DEPT_NONE
+
+ return ..(input, preferences)
+
+/datum/preference/choiced/engineering_department/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ // Job needs to be medium or high for the preference to show up
+ return preferences.job_preferences["Station Engineer"] >= JP_MEDIUM
+
+/datum/preference/choiced/engineering_department/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
diff --git a/code/modules/client/preferences/fps.dm b/code/modules/client/preferences/fps.dm
new file mode 100644
index 000000000000..f6cd8bf23bec
--- /dev/null
+++ b/code/modules/client/preferences/fps.dm
@@ -0,0 +1,20 @@
+/datum/preference/numeric/fps
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "clientfps"
+ savefile_identifier = PREFERENCE_PLAYER
+
+ minimum = -1
+ maximum = 240
+
+/datum/preference/numeric/fps/create_default_value()
+ return -1
+
+/datum/preference/numeric/fps/apply_to_client(client/client, value)
+ client.fps = (value < 0) ? RECOMMENDED_FPS : value
+
+/datum/preference/numeric/fps/compile_constant_data()
+ var/list/data = ..()
+
+ data["recommended_fps"] = RECOMMENDED_FPS
+
+ return data
diff --git a/code/modules/client/preferences/gender.dm b/code/modules/client/preferences/gender.dm
new file mode 100644
index 000000000000..d24b1954c2c6
--- /dev/null
+++ b/code/modules/client/preferences/gender.dm
@@ -0,0 +1,13 @@
+/// Gender preference
+/datum/preference/choiced/gender
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_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/ghost.dm b/code/modules/client/preferences/ghost.dm
new file mode 100644
index 000000000000..734e7ed8a651
--- /dev/null
+++ b/code/modules/client/preferences/ghost.dm
@@ -0,0 +1,181 @@
+/// Determines what accessories your ghost will look like they have.
+/datum/preference/choiced/ghost_accessories
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "ghost_accs"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/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_icon()
+
+/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
+ savefile_key = "ghost_form"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ should_generate_icons = TRUE
+
+ var/static/list/ghost_forms = list(
+ "catghost" = "Cat",
+ "ghost" = "Default",
+ "ghost_black" = "Black",
+ "ghost_blazeit" = "Blaze it",
+ "ghost_blue" = "Blue",
+ "ghost_camo" = "Camo",
+ "ghost_cyan" = "Cyan",
+ "ghost_dblue" = "Dark blue",
+ "ghost_dcyan" = "Dark cyan",
+ "ghost_dgreen" = "Dark green",
+ "ghost_dpink" = "Dark pink",
+ "ghost_dred" = "Dark red",
+ "ghost_dyellow" = "Dark yellow",
+ "ghost_fire" = "Fire",
+ "ghost_funkypurp" = "Funky purple",
+ "ghost_green" = "Green",
+ "ghost_grey" = "Grey",
+ "ghost_mellow" = "Mellow",
+ "ghost_pink" = "Pink",
+ "ghost_pinksherbert" = "Pink Sherbert",
+ "ghost_purpleswirl" = "Purple Swirl",
+ "ghost_rainbow" = "Rainbow",
+ "ghost_red" = "Red",
+ "ghost_yellow" = "Yellow",
+ "ghostian2" = "Ian",
+ "ghostking" = "King",
+ "skeleghost" = "Skeleton",
+ )
+
+/datum/preference/choiced/ghost_form/init_possible_values()
+ var/list/values = list()
+
+ for (var/ghost_form in 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 (!is_donator(client))
+ return
+
+ ghost.update_icon(value)
+
+/datum/preference/choiced/ghost_form/compile_constant_data()
+ var/list/data = ..()
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = ghost_forms
+
+ return data
+
+/// Toggles the HUD for ghosts
+/datum/preference/toggle/ghost_hud
+ savefile_key = "ghost_hud"
+ savefile_identifier = 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
+ savefile_key = "ghost_orbit"
+ savefile_identifier = 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/create_default_value()
+ return GHOST_ORBIT_DEFAULT_OPTION
+
+/datum/preference/choiced/ghost_orbit/apply_to_client(client/client, value)
+ var/mob/dead/observer/ghost = client.mob
+ if (!istype(ghost))
+ return
+
+ if (!is_donator(client))
+ return
+
+ ghost.ghost_orbit = value
+
+/// Determines how to show other ghosts
+/datum/preference/choiced/ghost_others
+ savefile_key = "ghost_others"
+ savefile_identifier = 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
+ savefile_key = "inquisitive_ghost"
+ savefile_identifier = PREFERENCE_PLAYER
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
diff --git a/code/modules/client/preferences/hotkeys.dm b/code/modules/client/preferences/hotkeys.dm
new file mode 100644
index 000000000000..b96b68286d60
--- /dev/null
+++ b/code/modules/client/preferences/hotkeys.dm
@@ -0,0 +1,7 @@
+/datum/preference/toggle/hotkeys
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "hotkeys"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/hotkeys/apply_to_client(client/client, value)
+ client.hotkeys = value
diff --git a/code/modules/client/preferences/items.dm b/code/modules/client/preferences/items.dm
new file mode 100644
index 000000000000..88ca0ff5179d
--- /dev/null
+++ b/code/modules/client/preferences/items.dm
@@ -0,0 +1,9 @@
+/datum/preference/toggle/spawn_flare
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "flare"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/spawn_map
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "map"
+ savefile_identifier = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/jobless_role.dm b/code/modules/client/preferences/jobless_role.dm
new file mode 100644
index 000000000000..4e368a251e0f
--- /dev/null
+++ b/code/modules/client/preferences/jobless_role.dm
@@ -0,0 +1,15 @@
+/datum/preference/choiced/jobless_role
+ savefile_key = "joblessrole"
+ savefile_identifier = PREFERENCE_CHARACTER
+
+/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
+
+/datum/preference/choiced/jobless_role/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/middleware/_middleware.dm b/code/modules/client/preferences/middleware/_middleware.dm
new file mode 100644
index 000000000000..8f47f73642c8
--- /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 000000000000..6deb9124f72b
--- /dev/null
+++ b/code/modules/client/preferences/middleware/antags.dm
@@ -0,0 +1,164 @@
+/datum/preference_middleware/antags
+ action_delegations = list(
+ "set_antags" = .proc/set_antags,
+ )
+
+/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/selected_antags = list()
+
+ for (var/antag in preferences.be_special)
+ selected_antags += serialize_antag_name(antag)
+
+ data["selected_antags"] = selected_antags
+
+ var/list/antag_bans = get_antag_bans()
+ if (antag_bans.len)
+ data["antag_bans"] = antag_bans
+
+ var/list/antag_days_left = get_antag_days_left()
+ if (antag_days_left?.len)
+ data["antag_days_left"] = antag_days_left
+
+ return data
+
+/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/antags = list()
+
+ var/serialized_antags = get_serialized_antags()
+
+ for (var/sent_antag in sent_antags)
+ var/special_role = serialized_antags[sent_antag]
+ if (!special_role)
+ continue
+
+ antags += special_role
+
+ if (toggled)
+ preferences.be_special |= antags
+ else
+ preferences.be_special -= antags
+
+ // This is predicted on the client
+ return FALSE
+
+/datum/preference_middleware/antags/proc/get_antag_bans()
+ var/list/antag_bans = list()
+
+ for (var/datum/dynamic_ruleset/dynamic_ruleset as anything in subtypesof(/datum/dynamic_ruleset))
+ var/antag_flag = initial(dynamic_ruleset.antag_flag)
+ var/antag_flag_override = initial(dynamic_ruleset.antag_flag_override)
+
+ if (isnull(antag_flag))
+ continue
+
+ if (is_banned_from(preferences.parent.ckey, list(antag_flag_override || antag_flag, ROLE_SYNDICATE)))
+ antag_bans += serialize_antag_name(antag_flag)
+
+ return antag_bans
+
+/datum/preference_middleware/antags/proc/get_antag_days_left()
+ if (!CONFIG_GET(flag/use_age_restriction_for_jobs))
+ return
+
+ var/list/antag_days_left = list()
+
+ for (var/datum/dynamic_ruleset/dynamic_ruleset as anything in subtypesof(/datum/dynamic_ruleset))
+ var/antag_flag = initial(dynamic_ruleset.antag_flag)
+ //var/antag_flag_override = initial(dynamic_ruleset.antag_flag_override)
+
+ if (isnull(antag_flag))
+ continue
+
+ /*var/days_needed = preferences.parent?.get_remaining_days(
+ GLOB.special_roles[antag_flag_override || antag_flag]
+ )*/
+
+ var/days_needed = 0
+
+ if (days_needed > 0)
+ antag_days_left[serialize_antag_name(antag_flag)] = days_needed
+
+ return antag_days_left
+
+/datum/preference_middleware/antags/proc/get_serialized_antags()
+ var/list/serialized_antags
+
+ if (isnull(serialized_antags))
+ serialized_antags = list()
+
+ for (var/special_role in GLOB.special_roles)
+ serialized_antags[serialize_antag_name(special_role)] = special_role
+
+ return serialized_antags
+
+/// Sprites generated for the antagonists panel
+/datum/asset/spritesheet/antagonists
+ name = "antagonists"
+ early = TRUE
+ cross_round_cachable = TRUE
+
+/datum/asset/spritesheet/antagonists/create_spritesheets()
+ // Antagonists that don't have a dynamic ruleset, but do have a preference
+ var/static/list/non_ruleset_antagonists = list(
+ ROLE_LONE_OPERATIVE = /datum/antagonist/nukeop/lone,
+ )
+
+ var/list/antagonists = GLOB.special_roles.Copy()
+
+ /*for (var/datum/dynamic_ruleset/ruleset as anything in subtypesof(/datum/dynamic_ruleset))
+ var/datum/antagonist/antagonist_type = initial(ruleset.antag_datum)
+ if (isnull(antagonist_type))
+ continue
+
+ // antag_flag is guaranteed to be unique by unit tests.
+ antagonists[initial(ruleset.antag_flag)] = antagonist_type
+ */
+ var/list/generated_icons = list()
+ var/list/to_insert = list()
+
+ for (var/antag_flag in antagonists)
+ var/datum/antagonist/antagonist_type = antagonists[antag_flag]
+
+ // antag_flag is guaranteed to be unique by unit tests.
+ var/spritesheet_key = serialize_antag_name(antag_flag)
+
+ if (!isnull(generated_icons[antagonist_type]))
+ to_insert[spritesheet_key] = generated_icons[antagonist_type]
+ continue
+
+ var/datum/antagonist/antagonist = new antagonist_type
+ var/icon/preview_icon = antagonist.get_preview_icon()
+
+ if (isnull(preview_icon))
+ continue
+
+ qdel(antagonist)
+
+ // 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[antagonist_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(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 000000000000..3aa91aba0046
--- /dev/null
+++ b/code/modules/client/preferences/middleware/jobs.dm
@@ -0,0 +1,132 @@
+/datum/preference_middleware/jobs
+ action_delegations = list(
+ "set_job_preference" = .proc/set_job_preference,
+ "set_alt_title" = .proc/set_alt_title,
+ )
+
+/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 != 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/proc/set_alt_title(list/params, mob/user)
+ var/job_title = params["job"]
+ var/alt_title = params["alt_title"]
+
+ var/datum/job/job = SSjob.GetJob(job_title)
+
+ if (isnull(job))
+ return FALSE
+
+ preferences.SetPlayerAltTitle(job, alt_title)
+
+ 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.joinable_occupations)
+ var/datum/job_department/department_type = job.department_for_prefs || job.departments_list?[1]
+ if (isnull(department_type))
+ 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 = initial(department_type.department_name)
+ if (isnull(departments[department_name]))
+ var/datum/job/department_head_type = initial(department_type.department_head)
+
+ departments[department_name] = list(
+ "head" = department_head_type && initial(department_head_type.title),
+ )
+
+ jobs[job.title] = list(
+ "description" = job.description,
+ "department" = department_name,
+ "alt_titles" = job.alt_titles
+ )
+
+ 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
+ data["job_alt_titles"] = preferences.player_alt_titles
+
+ 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)
+ 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 000000000000..bb771dc28f1c
--- /dev/null
+++ b/code/modules/client/preferences/middleware/keybindings.dm
@@ -0,0 +1,101 @@
+#define MAX_HOTKEY_SLOTS 3
+
+/// Middleware to handle keybindings
+/datum/preference_middleware/keybindings
+ action_delegations = list(
+ "reset_all_keybinds" = .proc/reset_all_keybinds,
+ "reset_keybinds_to_defaults" = .proc/reset_keybinds_to_defaults,
+ "set_keybindings" = .proc/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.key_bindings = deepCopyList(GLOB.default_hotkeys)
+ preferences.key_bindings_by_key = preferences.get_key_bindings_by_key(preferences.key_bindings)
+
+ preferences.update_static_data(user)
+ preferences.parent?.set_macros()
+
+ 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] = preferences.parent.hotkeys ? keybinding.hotkey_keys : keybinding.classic_keys
+ preferences.key_bindings_by_key = preferences.get_key_bindings_by_key(preferences.key_bindings)
+
+ preferences.update_static_data(user)
+ preferences.parent?.set_macros()
+
+ 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.key_bindings[keybind_name] = hotkeys
+ preferences.key_bindings_by_key = preferences.get_key_bindings_by_key(preferences.key_bindings)
+ preferences.parent?.set_macros()
+
+ 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/legacy_toggles.dm b/code/modules/client/preferences/middleware/legacy_toggles.dm
new file mode 100644
index 000000000000..757c1b83a409
--- /dev/null
+++ b/code/modules/client/preferences/middleware/legacy_toggles.dm
@@ -0,0 +1,166 @@
+/// In the before times, toggles were all stored in one bitfield.
+/// In order to preserve this existing data (and code) without massive
+/// migrations, this middleware attempts to handle this in a way
+/// transparent to the preferences UI itself.
+/// In the future, the existing toggles data should just be migrated to
+/// individual `/datum/preference/toggle`s.
+/datum/preference_middleware/legacy_toggles
+ // DO NOT ADD ANY NEW TOGGLES HERE!
+ // Use `/datum/preference/toggle` instead.
+ var/static/list/legacy_toggles = list(
+ "announce_login" = ANNOUNCE_LOGIN,
+ "combohud_lighting" = COMBOHUD_LIGHTING,
+ "deadmin_always" = DEADMIN_ALWAYS,
+ "deadmin_antagonist" = DEADMIN_ANTAGONIST,
+ "deadmin_position_head" = DEADMIN_POSITION_HEAD,
+ "deadmin_position_security" = DEADMIN_POSITION_SECURITY,
+ "deadmin_position_silicon" = DEADMIN_POSITION_SILICON,
+ "disable_arrivalrattle" = DISABLE_ARRIVALRATTLE,
+ "disable_deathrattle" = DISABLE_DEATHRATTLE,
+ "member_public" = MEMBER_PUBLIC,
+ "sound_adminhelp" = SOUND_ADMINHELP,
+ "sound_ambience" = SOUND_AMBIENCE,
+ "sound_announcements" = SOUND_ANNOUNCEMENTS,
+ "sound_instruments" = SOUND_INSTRUMENTS,
+ "sound_lobby" = SOUND_LOBBY,
+ "sound_midi" = SOUND_MIDI,
+ "sound_prayers" = SOUND_PRAYERS,
+ "sound_ship_ambience" = SOUND_SHIP_AMBIENCE,
+ )
+
+ var/static/list/legacy_extra_toggles = list(
+ "split_admin_tabs" = SPLIT_ADMIN_TABS,
+ "fast_mc_refresh" = FAST_MC_REFRESH,
+ )
+
+ var/static/list/legacy_chat_toggles = list(
+ "chat_bankcard" = CHAT_BANKCARD,
+ "chat_dead" = CHAT_DEAD,
+ "chat_ghostears" = CHAT_GHOSTEARS,
+ "chat_ghostpda" = CHAT_GHOSTPDA,
+ "chat_ghostradio" = CHAT_GHOSTRADIO,
+ "chat_ghostsight" = CHAT_GHOSTSIGHT,
+ "chat_ghostwhisper" = CHAT_GHOSTWHISPER,
+ "chat_ooc" = CHAT_OOC,
+ "chat_prayer" = CHAT_PRAYER,
+ "chat_pullr" = CHAT_PULLR,
+ )
+
+ var/static/list/legacy_yog_toggles = list(
+ "quiet_mode" = QUIET_ROUND,
+ "pref_mood" = PREF_MOOD,
+ )
+
+/datum/preference_middleware/legacy_toggles/get_character_preferences(mob/user)
+ if (preferences.current_window != PREFERENCE_TAB_GAME_PREFERENCES)
+ return list()
+
+ var/static/list/admin_only_legacy_toggles = list(
+ "admin_ignore_cult_ghost",
+ "announce_login",
+ "combohud_lighting",
+ "deadmin_always",
+ "deadmin_antagonist",
+ "deadmin_position_head",
+ "deadmin_position_security",
+ "deadmin_position_silicon",
+ "sound_adminhelp",
+ "sound_prayers",
+ )
+
+ var/static/list/admin_only_extra_toggles = list(
+ "split_admin_tabs",
+ "fast_mc_refresh",
+ )
+
+ var/static/list/admin_only_chat_toggles = list(
+ "chat_dead",
+ "chat_prayer",
+ )
+
+ var/static/list/donor_only_yog_toggles = list(
+ "quiet_mode",
+ )
+
+ var/static/list/deadmin_flags = list(
+ "deadmin_antagonist",
+ "deadmin_position_head",
+ "deadmin_position_security",
+ "deadmin_position_silicon",
+ )
+
+ var/list/new_game_preferences = list()
+ var/is_admin = is_admin(user.client)
+ var/is_donor = is_donator(user.client)
+
+ for (var/toggle_name in legacy_toggles)
+ if (!is_admin && (toggle_name in admin_only_legacy_toggles))
+ continue
+
+ if (is_admin && (toggle_name in deadmin_flags) && (preferences.toggles & DEADMIN_ALWAYS))
+ continue
+
+ if (toggle_name == "member_public" && !preferences.unlock_content)
+ continue
+
+ new_game_preferences[toggle_name] = (preferences.toggles & legacy_toggles[toggle_name]) != 0
+
+ for (var/toggle_name in legacy_extra_toggles)
+ if (!is_admin && (toggle_name in admin_only_extra_toggles))
+ continue
+
+ new_game_preferences[toggle_name] = (preferences.extra_toggles & legacy_extra_toggles[toggle_name]) != 0
+
+ for (var/toggle_name in legacy_chat_toggles)
+ if (!is_admin && (toggle_name in admin_only_chat_toggles))
+ continue
+
+ new_game_preferences[toggle_name] = (preferences.chat_toggles & legacy_chat_toggles[toggle_name]) != 0
+
+ for (var/toggle_name in legacy_yog_toggles)
+ if (!is_donor && (toggle_name in donor_only_yog_toggles))
+ continue
+
+ new_game_preferences[toggle_name] = (preferences.yogtoggles & legacy_yog_toggles[toggle_name]) != 0
+
+ return list(
+ PREFERENCE_CATEGORY_GAME_PREFERENCES = new_game_preferences,
+ )
+
+/datum/preference_middleware/legacy_toggles/pre_set_preference(mob/user, preference, value)
+ var/legacy_flag = legacy_toggles[preference]
+ if (!isnull(legacy_flag))
+ if (value)
+ preferences.toggles |= legacy_flag
+ else
+ preferences.toggles &= ~legacy_flag
+
+ // I know this looks silly, but this is the only one that cares
+ // and NO NEW LEGACY TOGGLES should ever be added.
+ if (legacy_flag == SOUND_LOBBY)
+ if (value && isnewplayer(user))
+ user.client?.playtitlemusic()
+ else
+ user.stop_sound_channel(CHANNEL_LOBBYMUSIC)
+
+ return TRUE
+
+ var/legacy_chat_flag = legacy_chat_toggles[preference]
+ if (!isnull(legacy_chat_flag))
+ if (value)
+ preferences.chat_toggles |= legacy_chat_flag
+ else
+ preferences.chat_toggles &= ~legacy_chat_flag
+
+ return TRUE
+
+ var/legacy_yog_flag = legacy_yog_toggles[preference]
+ if (!isnull(legacy_yog_flag))
+ if (value)
+ preferences.yogtoggles |= legacy_yog_flag
+ else
+ preferences.yogtoggles &= ~legacy_yog_flag
+
+ return TRUE
+
+ return FALSE
diff --git a/code/modules/client/preferences/middleware/names.dm b/code/modules/client/preferences/middleware/names.dm
new file mode 100644
index 000000000000..387d9129a578
--- /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 what names
+/// they have.
+/datum/preference_middleware/names
+ action_delegations = list(
+ "randomize_name" = .proc/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.savefile_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.savefile_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))
diff --git a/code/modules/client/preferences/middleware/quirks.dm b/code/modules/client/preferences/middleware/quirks.dm
new file mode 100644
index 000000000000..95f56bc4c1b0
--- /dev/null
+++ b/code/modules/client/preferences/middleware/quirks.dm
@@ -0,0 +1,113 @@
+/// Middleware to handle quirks
+/datum/preference_middleware/quirks
+ var/tainted = FALSE
+
+ action_delegations = list(
+ "give_quirk" = .proc/give_quirk,
+ "remove_quirk" = .proc/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()
+ data["locked_quirks"] = get_locked_quirks()
+
+ // If moods are globally enabled, or this guy does indeed have his mood pref set to Enabled
+ var/ismoody = (!CONFIG_GET(flag/disable_human_mood) || (user.client?.prefs.yogtoggles & PREF_MOOD))
+ data["mood_enabled"] = ismoody
+
+ 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()
+ data["locked_quirks"] = get_locked_quirks()
+
+ // If moods are globally enabled, or this guy does indeed have his mood pref set to Enabled
+ var/ismoody = (!CONFIG_GET(flag/disable_human_mood) || (user.client?.prefs.yogtoggles & PREF_MOOD))
+ data["mood_enabled"] = ismoody
+
+ return data
+
+/datum/preference_middleware/quirks/get_constant_data()
+ var/list/quirk_info = list()
+
+ for (var/quirk_name in SSquirks.quirks)
+ var/datum/quirk/quirk = SSquirks.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),
+ "mood" = initial(quirk.mood_quirk),
+ )
+
+ 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, user.client) != 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
+
+ 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, user.client) != 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
+
+ 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
+
+/datum/preference_middleware/quirks/proc/get_locked_quirks()
+ var/list/locked_quirks = list()
+
+ for (var/quirk_name in SSquirks.quirks)
+ var/datum/quirk/quirk_type = SSquirks.quirks[quirk_name]
+ var/datum/quirk/quirk = new quirk_type(no_init = TRUE)
+ var/lock_reason = quirk?.check_quirk(preferences)
+ if (lock_reason)
+ locked_quirks[sanitize_css_class_name(quirk_name)] = lock_reason
+ qdel(quirk)
+
+ return locked_quirks
diff --git a/code/modules/client/preferences/middleware/random.dm b/code/modules/client/preferences/middleware/random.dm
new file mode 100644
index 000000000000..17bc259e2c25
--- /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/randomize_character,
+ "set_random_preference" = .proc/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.savefile_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(src))
+
+ 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
+
+ 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.savefile_key]
+
+ if (istype(preference, /datum/preference/name))
+ requested_randomization = read_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/skillcapes.dm b/code/modules/client/preferences/middleware/skillcapes.dm
new file mode 100644
index 000000000000..bf6d9aad9519
--- /dev/null
+++ b/code/modules/client/preferences/middleware/skillcapes.dm
@@ -0,0 +1,22 @@
+/// Middleware for skillcapes
+
+/// Generate list of valid skillcapes
+/datum/preference_middleware/skillcape/get_ui_static_data()
+ . = list()
+ .["earned_skillcapes"] = list("None")
+
+ var/max_earned = TRUE
+ for(var/id in GLOB.skillcapes)
+ var/datum/skillcape/cape_check = GLOB.skillcapes[id]
+ if(!cape_check.job)
+ continue
+
+ if(preferences.exp[cape_check.job] < cape_check.minutes)
+ max_earned = FALSE
+ continue
+
+ .["earned_skillcapes"] += id
+
+ if(max_earned)
+ .["earned_skillcapes"] += "max"
+
diff --git a/code/modules/client/preferences/middleware/species.dm b/code/modules/client/preferences/middleware/species.dm
new file mode 100644
index 000000000000..05fffbb32811
--- /dev/null
+++ b/code/modules/client/preferences/middleware/species.dm
@@ -0,0 +1,36 @@
+/// 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)
+ var/icon/dummy_icon = getFlatIcon(dummy)
+ if(ismoth(dummy))
+ dummy_icon = null
+ dummy_icon = icon('icons/mob/human.dmi', "moth")
+ 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/mood.dm b/code/modules/client/preferences/mood.dm
new file mode 100644
index 000000000000..b37274fb756d
--- /dev/null
+++ b/code/modules/client/preferences/mood.dm
@@ -0,0 +1,4 @@
+/datum/preference/toggle/mood_tail_wagging
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "mood_tail_wagging"
+ savefile_identifier = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences/names.dm b/code/modules/client/preferences/names.dm
new file mode 100644
index 000000000000..52c865cb63c8
--- /dev/null
+++ b/code/modules/client/preferences/names.dm
@@ -0,0 +1,145 @@
+/// 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
+ savefile_identifier = 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"
+ savefile_key = "real_name"
+ allow_numbers = TRUE //fucking ipcs
+
+/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_preference(/datum/preference/choiced/species)
+ var/gender = preferences.read_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)
+ input = ..(input)
+ if (!input)
+ return input
+
+ if (CONFIG_GET(flag/humans_need_surnames) && preferences.read_preference(/datum/preference/choiced/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 reject_bad_name(input, allow_numbers)
+
+/// 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"
+ savefile_key = "human_name"
+
+/datum/preference/name/backup_human/create_informed_default_value(datum/preferences/preferences)
+ var/gender = preferences.read_preference(/datum/preference/choiced/gender)
+
+ return random_unique_name(gender)
+
+/datum/preference/name/clown
+ savefile_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
+ savefile_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
+ savefile_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
+ savefile_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
+ savefile_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
+ savefile_key = "deity_name"
+
+ allow_numbers = TRUE
+ can_randomize = FALSE
+
+ explanation = "Deity name"
+ group = "religion"
+
+/datum/preference/name/deity/create_default_value()
+ return DEFAULT_DEITY
diff --git a/code/modules/client/preferences/ooc.dm b/code/modules/client/preferences/ooc.dm
new file mode 100644
index 000000000000..de436391edc9
--- /dev/null
+++ b/code/modules/client/preferences/ooc.dm
@@ -0,0 +1,14 @@
+/// The color admins will speak in for OOC.
+/datum/preference/color/ooc_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "ooccolor"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/color/ooc_color/create_default_value()
+ return "#c43b23"
+
+/datum/preference/color/ooc_color/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return is_admin(preferences.parent) || preferences.unlock_content
diff --git a/code/modules/client/preferences/parallax.dm b/code/modules/client/preferences/parallax.dm
new file mode 100644
index 000000000000..24cccce2da62
--- /dev/null
+++ b/code/modules/client/preferences/parallax.dm
@@ -0,0 +1,38 @@
+/// Determines parallax, "fancy space"
+/datum/preference/choiced/parallax
+ savefile_key = "parallax"
+ savefile_identifier = 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/pda.dm b/code/modules/client/preferences/pda.dm
new file mode 100644
index 000000000000..2e05208a5b8e
--- /dev/null
+++ b/code/modules/client/preferences/pda.dm
@@ -0,0 +1,33 @@
+/// The color of a PDA
+/datum/preference/color/pda_color
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "pda_color"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/color/pda_color/create_default_value()
+ return COLOR_OLIVE
+
+/// The visual style of a PDA
+/datum/preference/choiced/pda_style
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "pda_style"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/pda_style/init_possible_values()
+ return GLOB.pda_styles
+
+/// The visual theme of a PDA
+/datum/preference/choiced/pda_theme
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "pda_theme"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/pda_theme/init_possible_values()
+ return GLOB.pda_themes
+
+/// Put ID into PDA when spawning
+/datum/preference/toggle/id_in_pda
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "id_in_pda"
+ savefile_identifier = PREFERENCE_PLAYER
+ default_value = FALSE
diff --git a/code/modules/client/preferences/persistent_scars.dm b/code/modules/client/preferences/persistent_scars.dm
new file mode 100644
index 000000000000..e9777e2517d8
--- /dev/null
+++ b/code/modules/client/preferences/persistent_scars.dm
@@ -0,0 +1,10 @@
+/datum/preference/toggle/persistent_scars
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "persistent_scars"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/toggle/persistent_scars/apply_to_human(mob/living/carbon/human/target, value)
+ // This proc doesn't do anything, due to the nature of persistent scars, we ALWAYS need to have a client to be able to use them properly. Or at the very least, a ckey.
+ // So we don't need to store this anywhere else, we simply search the preference when we need it.
+ return
diff --git a/code/modules/client/preferences/pixel_size.dm b/code/modules/client/preferences/pixel_size.dm
new file mode 100644
index 000000000000..cb166e0139ac
--- /dev/null
+++ b/code/modules/client/preferences/pixel_size.dm
@@ -0,0 +1,15 @@
+/datum/preference/numeric/pixel_size
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "pixel_size"
+ savefile_identifier = 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/preferred_map.dm b/code/modules/client/preferences/preferred_map.dm
new file mode 100644
index 000000000000..8c9f87e82322
--- /dev/null
+++ b/code/modules/client/preferences/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
+ savefile_key = "preferred_map"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/choiced/preferred_map/init_possible_values()
+ var/list/maps = list()
+ maps += ""
+
+ 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 ""
+
+/datum/preference/choiced/preferred_map/compile_constant_data()
+ var/list/data = ..()
+
+ var/display_names = list()
+
+ if (config.defaultmap)
+ display_names[""] = "Default ([config.defaultmap.map_name])"
+ else
+ display_names[""] = "Default"
+
+ for (var/choice in get_choices())
+ if (choice == "")
+ 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)
+ if (!..(preferences))
+ return FALSE
+
+ return CONFIG_GET(flag/preference_map_voting)
diff --git a/code/modules/client/preferences/random.dm b/code/modules/client/preferences/random.dm
new file mode 100644
index 000000000000..e8555e847487
--- /dev/null
+++ b/code/modules/client/preferences/random.dm
@@ -0,0 +1,53 @@
+/datum/preference/choiced/random_body
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "random_body"
+ savefile_identifier = 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/toggle/random_hardcore
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "random_hardcore"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+ default_value = FALSE
+
+/datum/preference/toggle/random_hardcore/apply_to_human(mob/living/carbon/human/target, value)
+ return
+
+/datum/preference/toggle/random_hardcore/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ return preferences.parent.get_exp_living(pure_numeric = TRUE) >= PLAYTIME_HARDCORE_RANDOM
+*/
+/datum/preference/choiced/random_name
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "random_name"
+ savefile_identifier = 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/runechat.dm b/code/modules/client/preferences/runechat.dm
new file mode 100644
index 000000000000..83282fefe36c
--- /dev/null
+++ b/code/modules/client/preferences/runechat.dm
@@ -0,0 +1,25 @@
+/datum/preference/toggle/enable_runechat
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "chat_on_map"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/enable_runechat_non_mobs
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "see_chat_non_mob"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/see_rc_emotes
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "see_rc_emotes"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/numeric/max_chat_length
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "max_chat_length"
+ savefile_identifier = PREFERENCE_PLAYER
+
+ minimum = 1
+ maximum = CHAT_MESSAGE_MAX_LENGTH
+
+/datum/preference/numeric/max_chat_length/create_default_value()
+ return CHAT_MESSAGE_MAX_LENGTH
diff --git a/code/modules/client/preferences/scaling_method.dm b/code/modules/client/preferences/scaling_method.dm
new file mode 100644
index 000000000000..63235abaf996
--- /dev/null
+++ b/code/modules/client/preferences/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
+ savefile_key = "scaling_method"
+ savefile_identifier = 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/security_department.dm b/code/modules/client/preferences/security_department.dm
new file mode 100644
index 000000000000..ba07aaff9dda
--- /dev/null
+++ b/code/modules/client/preferences/security_department.dm
@@ -0,0 +1,28 @@
+/// Which department to put security officers in
+/datum/preference/choiced/security_department
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_key = "prefered_security_department"
+ savefile_identifier = PREFERENCE_CHARACTER
+ can_randomize = FALSE
+
+/datum/preference/choiced/security_department/create_default_value()
+ return SEC_DEPT_NONE
+
+/datum/preference/choiced/security_department/init_possible_values()
+ return GLOB.security_depts_prefs
+
+/datum/preference/choiced/security_department/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.security_depts_prefs))
+ return SEC_DEPT_NONE
+
+ return ..(input, preferences)
+
+/datum/preference/choiced/security_department/is_accessible(datum/preferences/preferences)
+ if (!..(preferences))
+ return FALSE
+
+ // Job needs to be medium or high for the preference to show up
+ return preferences.job_preferences["Security Officer"] >= JP_MEDIUM
+
+/datum/preference/choiced/security_department/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/skillcape.dm b/code/modules/client/preferences/skillcape.dm
new file mode 100644
index 000000000000..37d64ad4d9e2
--- /dev/null
+++ b/code/modules/client/preferences/skillcape.dm
@@ -0,0 +1,30 @@
+/datum/preference/choiced/skillcape
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_identifier = PREFERENCE_PLAYER
+ savefile_key = "skillcape_id"
+ can_randomize = FALSE
+
+/datum/preference/choiced/skillcape/init_possible_values()
+ return list("None") + GLOB.skillcapes
+
+/datum/preference/choiced/skillcape/compile_constant_data()
+ var/list/data = ..()
+
+ var/list/cape_names = list("None" = "None")
+
+ for(var/cape_id in GLOB.skillcapes)
+ var/datum/skillcape/cape = GLOB.skillcapes[cape_id]
+ cape_names[cape.id] = cape.name
+
+ data[CHOICED_PREFERENCE_DISPLAY_NAMES] = cape_names
+
+ return data
+
+/datum/preference/choiced/skillcape/create_default_value()
+ return "None"
+
+/datum/preference/choiced/skillcape/deserialize(input, datum/preferences/preferences)
+ if (!(input in GLOB.skillcapes))
+ return "None"
+
+ return ..(input, preferences)
diff --git a/code/modules/client/preferences/skin_tone.dm b/code/modules/client/preferences/skin_tone.dm
new file mode 100644
index 000000000000..70aebc3521ec
--- /dev/null
+++ b/code/modules/client/preferences/skin_tone.dm
@@ -0,0 +1,36 @@
+/datum/preference/choiced/skin_tone
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_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)
+ 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)
+ if (!..(preferences))
+ return FALSE
+
+ var/datum/species/species_type = preferences.read_preference(/datum/preference/choiced/species)
+ return initial(species_type.use_skintones)
diff --git a/code/modules/client/preferences/species.dm b/code/modules/client/preferences/species.dm
new file mode 100644
index 000000000000..2ad93c3b14bf
--- /dev/null
+++ b/code/modules/client/preferences/species.dm
@@ -0,0 +1,52 @@
+/// Species preference
+/datum/preference/choiced/species
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_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_choices_serialized(), "human")]
+
+/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_selectable_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()
+
+ qdel(species)
+
+ return data
diff --git a/code/modules/client/preferences/species_features/basic.dm b/code/modules/client/preferences/species_features/basic.dm
new file mode 100644
index 000000000000..176116dd6440
--- /dev/null
+++ b/code/modules/client/preferences/species_features/basic.dm
@@ -0,0 +1,121 @@
+/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")]", 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(COLOR_DARK_BROWN, 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
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "eye_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_species_trait = EYECOLOR
+ unique = TRUE
+
+/datum/preference/color_legacy/eye_color/apply_to_human(mob/living/carbon/human/target, value)
+ 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/hairstyle
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "hair_style_name"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Hair style"
+ should_generate_icons = TRUE
+ relevant_species_trait = HAIR
+
+/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/color_legacy/hair_color
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "hair_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_species_trait = HAIR
+ unique = TRUE
+
+/datum/preference/color_legacy/hair_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.hair_color = value
+
+
+/datum/preference/choiced/facial_hairstyle
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "facial_style_name"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Facial hair"
+ should_generate_icons = TRUE
+ relevant_species_trait = FACEHAIR
+
+/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/color_legacy/facial_hair_color
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "facial_hair_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_species_trait = FACEHAIR
+ unique = TRUE
+
+/datum/preference/color_legacy/facial_hair_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.facial_hair_color = value
+
+
+/datum/preference/choiced/hair_gradient
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_gradientstyle"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_species_trait = HAIR
+
+/datum/preference/choiced/hair_gradient/init_possible_values()
+ return assoc_to_keys(GLOB.hair_gradients_list)
+
+/datum/preference/choiced/hair_gradient/apply_to_human(mob/living/carbon/human/target, value)
+ target.grad_style = value
+
+/datum/preference/choiced/hair_gradient/create_default_value()
+ return "None"
+
+
+/datum/preference/color_legacy/hair_gradient
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_gradientcolor"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_species_trait = HAIR
+
+/datum/preference/color_legacy/hair_gradient/apply_to_human(mob/living/carbon/human/target, value)
+ target.grad_color = value
diff --git a/code/modules/client/preferences/species_features/ethereal.dm b/code/modules/client/preferences/species_features/ethereal.dm
new file mode 100644
index 000000000000..cbdd949a8607
--- /dev/null
+++ b/code/modules/client/preferences/species_features/ethereal.dm
@@ -0,0 +1,46 @@
+/datum/preference/choiced/ethereal_color
+ savefile_key = "feature_ethcolor"
+ savefile_identifier = 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/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ethcolor"] = GLOB.color_list_ethereal[value]
+
+
+/datum/preference/choiced/ethereal_mark
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_ethereal_mark"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_mutant_bodypart = "ethereal_mark"
+
+/datum/preference/choiced/ethereal_mark/init_possible_values()
+ return assoc_to_keys(GLOB.ethereal_mark_list)
+
+/datum/preference/choiced/ethereal_mark/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ethereal_mark"] = value
diff --git a/code/modules/client/preferences/species_features/felinid.dm b/code/modules/client/preferences/species_features/felinid.dm
new file mode 100644
index 000000000000..bc5445cd2fd0
--- /dev/null
+++ b/code/modules/client/preferences/species_features/felinid.dm
@@ -0,0 +1,33 @@
+/datum/preference/choiced/tail_human
+ savefile_key = "feature_human_tail"
+ savefile_identifier = 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_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
+ savefile_key = "feature_human_ears"
+ savefile_identifier = 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/species_features/ipc.dm b/code/modules/client/preferences/species_features/ipc.dm
new file mode 100644
index 000000000000..d7b34cdf75f0
--- /dev/null
+++ b/code/modules/client/preferences/species_features/ipc.dm
@@ -0,0 +1,68 @@
+/datum/preference/choiced/ipc_screen
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_ipc_screen"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "IPC screen"
+ relevant_mutant_bodypart = "ipc_screen"
+
+/datum/preference/choiced/ipc_screen/init_possible_values()
+ return GLOB.ipc_screens_list
+
+/datum/preference/choiced/ipc_screen/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ipc_screen"] = value
+
+
+/datum/preference/color_legacy/ipc_screen_color
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "eye_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_mutant_bodypart = "ipc_screen"
+ unique = TRUE
+
+/datum/preference/color_legacy/ipc_screen_color/apply_to_human(mob/living/carbon/human/target, value)
+ 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/choiced/ipc_antenna
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_ipc_antenna"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "IPC antenna"
+ relevant_mutant_bodypart = "ipc_antenna"
+
+/datum/preference/choiced/ipc_antenna/init_possible_values()
+ return GLOB.ipc_antennas_list
+
+/datum/preference/choiced/ipc_antenna/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["ipc_antenna"] = value
+
+
+/datum/preference/color_legacy/ipc_antenna_color
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "hair_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_mutant_bodypart = "ipc_antenna"
+ unique = TRUE
+
+/datum/preference/color_legacy/ipc_antenna_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.hair_color = value
+
+
+/datum/preference/choiced/ipc_chassis
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_ipc_chassis"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "IPC chassis"
+ relevant_mutant_bodypart = "ipc_chassis"
+
+/datum/preference/choiced/ipc_chassis/init_possible_values()
+ return GLOB.ipc_chassis_list
+
+/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/species_features/lizard.dm b/code/modules/client/preferences/species_features/lizard.dm
new file mode 100644
index 000000000000..ec7f86d57601
--- /dev/null
+++ b/code/modules/client/preferences/species_features/lizard.dm
@@ -0,0 +1,180 @@
+/proc/generate_lizard_side_shots(list/sprite_accessories, key, include_snout = TRUE)
+ var/list/values = list()
+
+ var/icon/lizard = icon('icons/mob/human_parts_greyscale.dmi', "lizard_head_m", EAST)
+
+ var/icon/eyes = icon('icons/mob/human_face.dmi', "eyes", 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", EAST), ICON_OVERLAY)
+
+ for (var/name in sprite_accessories)
+ var/datum/sprite_accessory/sprite_accessory = sprite_accessories[name]
+
+ var/icon/final_icon = icon(lizard)
+
+ if (sprite_accessory.icon_state != "none")
+ var/icon/accessory_icon = icon(sprite_accessory.icon, "m_[key]_[sprite_accessory.icon_state]_ADJ", EAST)
+ final_icon.Blend(accessory_icon, ICON_OVERLAY)
+
+ final_icon.Crop(11, 20, 23, 32)
+ final_icon.Scale(32, 32)
+ final_icon.Blend(COLOR_VIBRANT_LIME, ICON_MULTIPLY)
+
+ values[name] = final_icon
+
+ return values
+
+
+/datum/preference/choiced/lizard_body_markings
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_lizard_body_markings"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Body markings"
+ relevant_mutant_bodypart = "body_markings"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/lizard_body_markings/init_possible_values()
+ var/list/values = list()
+
+ var/icon/lizard = icon('icons/mob/human_parts_greyscale.dmi', "lizard_chest_m")
+
+ 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)
+
+ 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",
+ )
+
+ final_icon.Blend(body_markings_icon, ICON_OVERLAY)
+
+ final_icon.Blend(COLOR_VIBRANT_LIME, ICON_MULTIPLY)
+ final_icon.Crop(10, 8, 22, 23)
+ final_icon.Scale(26, 32)
+ final_icon.Crop(-2, 1, 29, 32)
+
+ values[name] = final_icon
+
+ 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
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_lizard_frills"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Frills"
+ relevant_mutant_bodypart = "frills"
+ should_generate_icons = TRUE
+
+/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
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_lizard_horns"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Horns"
+ relevant_mutant_bodypart = "horns"
+ should_generate_icons = TRUE
+
+/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
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_lizard_legs"
+ savefile_identifier = PREFERENCE_CHARACTER
+ 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
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_lizard_snout"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_mutant_bodypart = "snout"
+
+/datum/preference/choiced/lizard_snout/init_possible_values()
+ return assoc_to_keys(GLOB.snouts_list)
+
+/datum/preference/choiced/lizard_snout/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["snout"] = value
+
+
+/datum/preference/choiced/lizard_horns
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_lizard_horns"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Horns"
+ relevant_mutant_bodypart = "horns"
+ should_generate_icons = TRUE
+
+
+/datum/preference/choiced/lizard_horns/init_possible_values()
+ return generate_lizard_side_shots(GLOB.horns_list, "horns", include_snout = FALSE)
+
+/datum/preference/choiced/lizard_horns/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["horns"] = value
+
+
+/datum/preference/choiced/lizard_frills
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_lizard_frills"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Frills"
+ relevant_mutant_bodypart = "frills"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/lizard_frills/init_possible_values()
+ return generate_lizard_side_shots(GLOB.frills_list, "frills", include_snout = FALSE)
+
+/datum/preference/choiced/lizard_frills/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["frills"] = value
+
+
+/datum/preference/choiced/lizard_spines
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_lizard_spines"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_mutant_bodypart = "spines"
+
+/datum/preference/choiced/lizard_spines/init_possible_values()
+ return assoc_to_keys(GLOB.spines_list)
+
+/datum/preference/choiced/lizard_spines/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["spines"] = value
+
+
+/datum/preference/choiced/lizard_tail
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_lizard_tail"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_mutant_bodypart = "tail_lizard"
+
+/datum/preference/choiced/lizard_tail/init_possible_values()
+ return assoc_to_keys(GLOB.tails_list_lizard)
+
+/datum/preference/choiced/lizard_tail/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["tail_lizard"] = value
diff --git a/code/modules/client/preferences/species_features/moth.dm b/code/modules/client/preferences/species_features/moth.dm
new file mode 100644
index 000000000000..0a9708aeeb13
--- /dev/null
+++ b/code/modules/client/preferences/species_features/moth.dm
@@ -0,0 +1,24 @@
+/datum/preference/choiced/moth_wings
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_moth_wings"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Moth wings"
+ relevant_mutant_bodypart = "moth_wings"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/moth_wings/init_possible_values()
+ var/list/icon/values = possible_values_for_sprite_accessory_list_for_body_part(
+ GLOB.moth_wings_list,
+ "moth_wings",
+ list("ADJ", "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/species_features/mutants.dm b/code/modules/client/preferences/species_features/mutants.dm
new file mode 100644
index 000000000000..0f8cfff73b68
--- /dev/null
+++ b/code/modules/client/preferences/species_features/mutants.dm
@@ -0,0 +1,20 @@
+/datum/preference/color_legacy/mutant_color
+ savefile_key = "feature_mcolor"
+ savefile_identifier = 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), 22))
+ return FALSE
+
+ return TRUE
diff --git a/code/modules/client/preferences/species_features/plasmaman.dm b/code/modules/client/preferences/species_features/plasmaman.dm
new file mode 100644
index 000000000000..9dfec16fb7d2
--- /dev/null
+++ b/code/modules/client/preferences/species_features/plasmaman.dm
@@ -0,0 +1,24 @@
+/datum/preference/choiced/plasmaman_helmet
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_plasmaman_helmet"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Plasmaman helmet"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "plasmaman_helmet"
+
+/datum/preference/choiced/plasmaman_helmet/init_possible_values()
+ var/list/values = list()
+
+ for (var/helmet_name in GLOB.plasmaman_helmet_list)
+ var/datum/sprite_accessory/helmet_icon_suffix = GLOB.plasmaman_helmet_list[helmet_name]
+ var/helmet_icon = "purple_envirohelm"
+ if (helmet_name != "None")
+ helmet_icon += "-[helmet_icon_suffix]"
+
+ var/icon/icon = icon('icons/obj/clothing/hats.dmi', helmet_icon)
+ values[helmet_name] = icon
+
+ return values
+
+/datum/preference/choiced/plasmaman_helmet/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["plasmaman_helmet"] = value
diff --git a/code/modules/client/preferences/species_features/pod.dm b/code/modules/client/preferences/species_features/pod.dm
new file mode 100644
index 000000000000..9747b9a2f6e3
--- /dev/null
+++ b/code/modules/client/preferences/species_features/pod.dm
@@ -0,0 +1,72 @@
+/datum/preference/choiced/pod_hair
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_pod_hair"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Pod hair"
+ should_generate_icons = TRUE
+ relevant_mutant_bodypart = "pod_hair"
+
+/datum/preference/choiced/pod_hair/init_possible_values()
+ var/list/values = list()
+
+ var/icon/pod_head = icon('icons/mob/human_parts_greyscale.dmi', "pod_head_m")
+
+ for (var/pod_name in GLOB.pod_hair_list)
+ var/datum/sprite_accessory/pod_hair = GLOB.pod_hair_list[pod_name]
+
+ var/icon/icon_with_hair = new(pod_head)
+ var/icon/icon = icon(pod_hair.icon, pod_hair.icon_state)
+ icon_with_hair.Blend(icon, ICON_OVERLAY)
+ icon_with_hair.Scale(64, 64)
+ icon_with_hair.Crop(15, 64, 15 + 31, 64 - 31)
+ icon_with_hair.Blend(COLOR_GREEN, ICON_MULTIPLY)
+
+ values[pod_hair.name] = icon_with_hair
+
+ return values
+
+/datum/preference/choiced/pod_hair/create_default_value()
+ return pick(assoc_to_keys(GLOB.pod_hair_list))
+
+/datum/preference/choiced/pod_hair/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["pod_hair"] = value
+ target.dna.features["pod_flower"] = value
+
+
+/datum/preference/color_legacy/pod_hair_color
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "hair_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_mutant_bodypart = "pod_hair"
+ unique = TRUE
+
+/datum/preference/color_legacy/pod_hair_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.hair_color = value
+
+/datum/preference/color_legacy/pod_hair_color/is_valid(value)
+ if (!..(value))
+ return FALSE
+
+ if (is_color_dark(expand_three_digit_color(value), 22))
+ return FALSE
+
+ return TRUE
+
+/datum/preference/color_legacy/pod_flower_color
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "facial_hair_color"
+ savefile_identifier = PREFERENCE_CHARACTER
+ relevant_mutant_bodypart = "pod_flower"
+ unique = TRUE
+
+/datum/preference/color_legacy/pod_flower_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.facial_hair_color = value
+
+/datum/preference/color_legacy/pod_flower_color/is_valid(value)
+ if (!..(value))
+ return FALSE
+
+ if (is_color_dark(expand_three_digit_color(value), 22))
+ return FALSE
+
+ return TRUE
diff --git a/code/modules/client/preferences/species_features/polysmorph.dm b/code/modules/client/preferences/species_features/polysmorph.dm
new file mode 100644
index 000000000000..385346d36a49
--- /dev/null
+++ b/code/modules/client/preferences/species_features/polysmorph.dm
@@ -0,0 +1,54 @@
+/datum/preference/choiced/polysmorph_tail
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_polysmorph_tail"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Polysmorph tail"
+ relevant_mutant_bodypart = "tail_polysmorph"
+
+/datum/preference/choiced/polysmorph_tail/init_possible_values()
+ return GLOB.tails_list_polysmorph
+
+/datum/preference/choiced/polysmorph_tail/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["tail_polysmorph"] = value
+
+
+/datum/preference/choiced/polysmorph_teeth
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_polysmorph_teeth"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Polysmorph teeth"
+ relevant_mutant_bodypart = "teeth"
+
+/datum/preference/choiced/polysmorph_teeth/init_possible_values()
+ return GLOB.teeth_list
+
+/datum/preference/choiced/polysmorph_teeth/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["teeth"] = value
+
+
+/datum/preference/choiced/polysmorph_dome
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_polysmorph_dome"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Polysmorph dome"
+ relevant_mutant_bodypart = "dome"
+
+/datum/preference/choiced/polysmorph_dome/init_possible_values()
+ return GLOB.dome_list
+
+/datum/preference/choiced/polysmorph_dome/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["dome"] = value
+
+
+/datum/preference/choiced/polysmorph_dorsal_tubes
+ category = PREFERENCE_CATEGORY_SECONDARY_FEATURES
+ savefile_key = "feature_polysmorph_dorsal_tubes"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Polysmorph dorsal tubes"
+ relevant_mutant_bodypart = "dorsal_tubes"
+
+/datum/preference/choiced/polysmorph_dorsal_tubes/init_possible_values()
+ return GLOB.dorsal_tubes_list
+
+/datum/preference/choiced/polysmorph_dorsal_tubes/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["dorsal_tubes"] = value
diff --git a/code/modules/client/preferences/species_features/preternis.dm b/code/modules/client/preferences/species_features/preternis.dm
new file mode 100644
index 000000000000..194661d4d656
--- /dev/null
+++ b/code/modules/client/preferences/species_features/preternis.dm
@@ -0,0 +1,33 @@
+/datum/preference/choiced/preternis_color
+ category = PREFERENCE_CATEGORY_FEATURES
+ savefile_key = "feature_pretcolor"
+ savefile_identifier = PREFERENCE_CHARACTER
+ main_feature_name = "Preternis color"
+ should_generate_icons = TRUE
+
+/datum/preference/choiced/preternis_color/init_possible_values()
+ var/list/values = list()
+
+ var/icon/preternis_base = icon('icons/mob/human_parts_greyscale.dmi', "preternis_head_m")
+ preternis_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "preternis_chest_m"), ICON_OVERLAY)
+ preternis_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "preternis_l_arm"), ICON_OVERLAY)
+ preternis_base.Blend(icon('icons/mob/human_parts_greyscale.dmi', "preternis_r_arm"), ICON_OVERLAY)
+
+ var/icon/eyes = icon('icons/mob/human_face.dmi', "eyes")
+ eyes.Blend(COLOR_RED, ICON_MULTIPLY)
+ preternis_base.Blend(eyes, ICON_OVERLAY)
+
+ preternis_base.Scale(64, 64)
+ preternis_base.Crop(15, 64, 15 + 31, 64 - 31)
+
+ for (var/name in GLOB.color_list_preternis)
+ var/color = GLOB.color_list_preternis[name]
+
+ var/icon/icon = new(preternis_base)
+ icon.Blend("#[color]", ICON_MULTIPLY)
+ values[name] = icon
+
+ return values
+
+/datum/preference/choiced/preternis_color/apply_to_human(mob/living/carbon/human/target, value)
+ target.dna.features["pretcolor"] = GLOB.color_list_preternis[value]
diff --git a/code/modules/client/preferences/tgui.dm b/code/modules/client/preferences/tgui.dm
new file mode 100644
index 000000000000..44a8666e642b
--- /dev/null
+++ b/code/modules/client/preferences/tgui.dm
@@ -0,0 +1,20 @@
+/datum/preference/toggle/tgui_fancy
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "tgui_fancy"
+ savefile_identifier = 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
+ savefile_key = "tgui_lock"
+ savefile_identifier = 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)
diff --git a/code/modules/client/preferences/tgui_prefs_migration.dm b/code/modules/client/preferences/tgui_prefs_migration.dm
new file mode 100644
index 000000000000..c7ea1f5836fb
--- /dev/null
+++ b/code/modules/client/preferences/tgui_prefs_migration.dm
@@ -0,0 +1,30 @@
+/// Handle the migrations necessary from pre-tgui prefs to post-tgui prefs
+/datum/preferences/proc/migrate_preferences_to_tgui_prefs_menu()
+ migrate_key_bindings()
+
+/// Handle the migrations necessary from pre-tgui prefs to post-tgui prefs, for characters
+/datum/preferences/proc/migrate_character_to_tgui_prefs_menu()
+ //migrate_hair()
+
+// Key bindings used to be "key" -> list("action"),
+// such as "X" -> list("swap_hands").
+// This made it impossible to determine any order, meaning placing a new
+// hotkey would produce non-deterministic order.
+// tgui prefs menu moves this over to "swap_hands" -> list("X").
+/datum/preferences/proc/migrate_key_bindings()
+ var/new_key_bindings = list()
+
+ for (var/unbound_hotkey in key_bindings["Unbound"])
+ new_key_bindings[unbound_hotkey] = list()
+
+ for (var/hotkey in key_bindings)
+ if (hotkey == "Unbound")
+ continue
+
+ for (var/keybind in key_bindings[hotkey])
+ if (keybind in new_key_bindings)
+ new_key_bindings[keybind] |= hotkey
+ else
+ new_key_bindings[keybind] = list(hotkey)
+
+ key_bindings = new_key_bindings
diff --git a/code/modules/client/preferences/tooltips.dm b/code/modules/client/preferences/tooltips.dm
new file mode 100644
index 000000000000..fbbea5085b10
--- /dev/null
+++ b/code/modules/client/preferences/tooltips.dm
@@ -0,0 +1,15 @@
+/datum/preference/toggle/enable_tooltips
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "enable_tips"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/numeric/tooltip_delay
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "tip_delay"
+ savefile_identifier = PREFERENCE_PLAYER
+
+ minimum = 0
+ maximum = 5000
+
+/datum/preference/numeric/tooltip_delay/create_default_value()
+ return 500 // in ms
diff --git a/code/modules/client/preferences/ui_style.dm b/code/modules/client/preferences/ui_style.dm
new file mode 100644
index 000000000000..08f1af6c7dd5
--- /dev/null
+++ b/code/modules/client/preferences/ui_style.dm
@@ -0,0 +1,26 @@
+/// UI style preference
+/datum/preference/choiced/ui_style
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_identifier = PREFERENCE_PLAYER
+ savefile_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))
diff --git a/code/modules/client/preferences/uplink_location.dm b/code/modules/client/preferences/uplink_location.dm
new file mode 100644
index 000000000000..b2e6056cca58
--- /dev/null
+++ b/code/modules/client/preferences/uplink_location.dm
@@ -0,0 +1,26 @@
+/datum/preference/choiced/uplink_location
+ category = PREFERENCE_CATEGORY_NON_CONTEXTUAL
+ savefile_identifier = PREFERENCE_CHARACTER
+ savefile_key = "uplink_loc"
+ can_randomize = FALSE
+
+/datum/preference/choiced/uplink_location/create_default_value()
+ return UPLINK_PDA
+
+/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/apply_to_human(mob/living/carbon/human/target, value)
+ return
diff --git a/code/modules/client/preferences/widescreen.dm b/code/modules/client/preferences/widescreen.dm
new file mode 100644
index 000000000000..1041a4f6f272
--- /dev/null
+++ b/code/modules/client/preferences/widescreen.dm
@@ -0,0 +1,7 @@
+/datum/preference/toggle/widescreen
+ category = PREFERENCE_CATEGORY_GAME_PREFERENCES
+ savefile_key = "widescreenpref"
+ savefile_identifier = PREFERENCE_PLAYER
+
+/datum/preference/toggle/widescreen/apply_to_client(client/client, value)
+ client.view_size?.setDefault(getScreenSize(value))
diff --git a/code/modules/client/preferences/window_flashing.dm b/code/modules/client/preferences/window_flashing.dm
new file mode 100644
index 000000000000..687315387c63
--- /dev/null
+++ b/code/modules/client/preferences/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
+ savefile_key = "windowflashing"
+ savefile_identifier = PREFERENCE_PLAYER
diff --git a/code/modules/client/preferences_menu.dm b/code/modules/client/preferences_menu.dm
new file mode 100644
index 000000000000..0a8f1653bc0f
--- /dev/null
+++ b/code/modules/client/preferences_menu.dm
@@ -0,0 +1,25 @@
+/datum/verbs/menu/Preferences/verb/open_character_preferences()
+ set category = "Preferences"
+ set name = "Open 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)
+
+/datum/verbs/menu/Preferences/verb/open_game_preferences()
+ set category = "Preferences"
+ set name = "Open 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_savefile.dm b/code/modules/client/preferences_savefile.dm
index 814ba86be77a..d1a1258850f2 100644
--- a/code/modules/client/preferences_savefile.dm
+++ b/code/modules/client/preferences_savefile.dm
@@ -5,7 +5,7 @@
// You do not need to raise this if you are adding new values that have sane defaults.
// Only raise this value when changing the meaning/format/name/layout of an existing value
// where you would want the updater procs below to run
-#define SAVEFILE_VERSION_MAX 39
+#define SAVEFILE_VERSION_MAX 40
/*
SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Carn
@@ -47,23 +47,25 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
if(LAZYFIND(be_special,"Ragin"))
be_special -= "Ragin"
be_special += "Ragin Mages"
+
if (current_version < 35)
toggles |= SOUND_ALT
+
if (current_version < 37)
chat_toggles |= CHAT_TYPING_INDICATOR
+
if (current_version < 39)
- key_bindings = (hotkeys) ? deepCopyList(GLOB.hotkey_keybinding_list_by_key) : deepCopyList(GLOB.classic_keybinding_list_by_key)
+ write_preference(/datum/preference/toggle/hotkeys, TRUE)
+ key_bindings = deepCopyList(GLOB.default_hotkeys)
+ key_bindings_by_key = get_key_bindings_by_key(key_bindings)
parent.set_macros()
- to_chat(parent, "Empty keybindings, setting default to [hotkeys ? "Hotkey" : "Classic"] mode ")
- return
+ to_chat(parent, span_userdanger("Empty keybindings, setting default to Hotkey mode"))
+
+ if (current_version < 40)
+ migrate_preferences_to_tgui_prefs_menu()
+
/datum/preferences/proc/update_character(current_version, savefile/S)
- if(current_version < 19)
- pda_style = "mono"
- if(current_version < 20)
- pda_color = "#808000"
- if((current_version < 21) && features["ethcolor"] && (features["ethcolor"] == "#9c3030"))
- features["ethcolor"] = "9c3030"
if(current_version < 22)
job_preferences = list() //It loaded null from nonexistant savefile field.
var/job_civilian_high = 0
@@ -121,66 +123,47 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
all_quirks -= "Physically Obstructive"
all_quirks -= "Neat"
all_quirks -= "NEET"
- if(current_version < 26) //The new donator hats system obsolesces the old one entirely, we need to update.
- donor_hat = null
- donor_item = null
- if(current_version < 27)
- map = TRUE
- flare = TRUE
if(current_version < 28)
if(!job_preferences)
job_preferences = list()
- if(current_version < 29)
- purrbation = FALSE
- if(current_version < 30) //Someone doesn't know how to code and make savefiles get corrupted
- if(!ispath(donor_hat))
- donor_hat = null
- if(!ispath(donor_item))
- donor_item = null
if(current_version < 31) //Someone doesn't know how to code and make jukebox and autodeadmin the same thing
toggles &= ~DEADMIN_ALWAYS
toggles &= ~DEADMIN_ANTAGONIST
toggles &= ~DEADMIN_POSITION_HEAD
toggles &= ~DEADMIN_POSITION_SECURITY
toggles &= ~DEADMIN_POSITION_SILICON //This last one is technically a no-op but it looks cleaner and less like someone forgot
- if(current_version < 32) // Changed skillcape storage
- if(skillcape != 1)
- var/path = subtypesof(/datum/skillcape)[skillcape]
- var/datum/skillcape/cape = new path()
- skillcape_id = cape.id
- qdel(cape)
- if(current_version < 33) //Reset map preference to no choice
- if(preferred_map)
- to_chat(parent, span_userdanger("Your preferred map has been reset to nothing. Please set it to the map you wish to play on."))
- preferred_map = null
if(current_version < 34) // default to on
toggles |= SOUND_VOX
+
+ if (current_version < 40)
+ migrate_character_to_tgui_prefs_menu()
/// checks through keybindings for outdated unbound keys and updates them
/datum/preferences/proc/check_keybindings()
if(!parent)
return
- var/list/user_binds = list()
- for (var/key in key_bindings)
- for(var/kb_name in key_bindings[key])
- user_binds[kb_name] += list(key)
+ var/list/binds_by_key = get_key_bindings_by_key(key_bindings)
var/list/notadded = list()
for (var/name in GLOB.keybindings_by_name)
var/datum/keybinding/kb = GLOB.keybindings_by_name[name]
- if(length(user_binds[kb.name]))
+ if(kb.name in key_bindings)
continue // key is unbound and or bound to something
+
var/addedbind = FALSE
- if(hotkeys)
+ key_bindings[kb.name] = list()
+
+ if(parent.hotkeys)
for(var/hotkeytobind in kb.hotkey_keys)
- if(!length(key_bindings[hotkeytobind]) || hotkeytobind == "Unbound") //Only bind to the key if nothing else is bound expect for Unbound
- LAZYADD(key_bindings[hotkeytobind], kb.name)
+ if(!length(binds_by_key[hotkeytobind]) && hotkeytobind != "Unbound") //Only bind to the key if nothing else is bound expect for Unbound
+ key_bindings[kb.name] |= hotkeytobind
addedbind = TRUE
else
for(var/classickeytobind in kb.classic_keys)
- if(!length(key_bindings[classickeytobind]) || classickeytobind == "Unbound") //Only bind to the key if nothing else is bound expect for Unbound
- LAZYADD(key_bindings[classickeytobind], kb.name)
+ if(!length(binds_by_key[classickeytobind]) && classickeytobind != "Unbound") //Only bind to the key if nothing else is bound expect for Unbound
+ key_bindings[kb.name] |= classickeytobind
addedbind = TRUE
+
if(!addedbind)
notadded += kb
save_preferences() //Save the players pref so that new keys that were set to Unbound as default are permanently stored
@@ -189,7 +172,7 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
/datum/preferences/proc/announce_conflict(list/notadded)
to_chat(parent, "Keybinding Conflict \n\
- There are new keybindings that default to keys you've already bound. The new ones will be unbound. ")
+ There are new keybindings that default to keys you've already bound. The new ones will be unbound. ")
for(var/item in notadded)
var/datum/keybinding/conflicted = item
to_chat(parent, span_danger("[conflicted.category]: [conflicted.full_name] needs updating"))
@@ -212,144 +195,42 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
var/needs_update = savefile_needs_update(S)
if(needs_update == -2) //fatal, can't load any data
+ var/bacpath = "[path].updatebac" //todo: if the savefile version is higher then the server, check the backup, and give the player a prompt to load the backup
+ if (fexists(bacpath))
+ fdel(bacpath) //only keep 1 version of backup
+ fcopy(S, bacpath) //byond helpfully lets you use a savefile for the first arg.
return FALSE
+
+ apply_all_client_preferences()
//general preferences
- READ_FILE(S["asaycolor"], asaycolor)
- READ_FILE(S["ooccolor"], ooccolor)
READ_FILE(S["lastchangelog"], lastchangelog)
- READ_FILE(S["UI_style"], UI_style)
- READ_FILE(S["hotkeys"], hotkeys)
- READ_FILE(S["chat_on_map"], chat_on_map)
- READ_FILE(S["max_chat_length"], max_chat_length)
- READ_FILE(S["see_chat_non_mob"] , see_chat_non_mob)
- READ_FILE(S["see_rc_emotes"] , see_rc_emotes)
- READ_FILE(S["tgui_fancy"], tgui_fancy)
- READ_FILE(S["tgui_lock"], tgui_lock)
- READ_FILE(S["buttons_locked"], buttons_locked)
- READ_FILE(S["windowflash"], windowflashing)
READ_FILE(S["be_special"] , be_special)
- READ_FILE(S["player_alt_titles"], player_alt_titles)
-
READ_FILE(S["default_slot"], default_slot)
+
+ READ_FILE(S["toggles"], toggles)
READ_FILE(S["chat_toggles"], chat_toggles)
READ_FILE(S["extra_toggles"], extra_toggles)
- READ_FILE(S["toggles"], toggles)
- READ_FILE(S["ghost_form"], ghost_form)
- READ_FILE(S["ghost_orbit"], ghost_orbit)
- READ_FILE(S["ghost_accs"], ghost_accs)
- READ_FILE(S["ghost_others"], ghost_others)
- READ_FILE(S["preferred_map"], preferred_map)
- READ_FILE(S["ignoring"], ignoring)
- READ_FILE(S["ghost_hud"], ghost_hud)
- READ_FILE(S["inquisitive_ghost"], inquisitive_ghost)
- READ_FILE(S["uses_glasses_colour"], uses_glasses_colour)
- READ_FILE(S["clientfps"], clientfps)
- READ_FILE(S["parallax"], parallax)
- READ_FILE(S["ambientocclusion"], ambientocclusion)
- READ_FILE(S["auto_fit_viewport"], auto_fit_viewport)
- READ_FILE(S["widescreenpref"], widescreenpref)
- READ_FILE(S["pixel_size"], pixel_size)
- READ_FILE(S["scaling_method"], scaling_method)
- READ_FILE(S["menuoptions"], menuoptions)
- READ_FILE(S["enable_tips"], enable_tips)
- READ_FILE(S["tip_delay"], tip_delay)
- READ_FILE(S["pda_style"], pda_style)
- READ_FILE(S["pda_color"], pda_color)
- READ_FILE(S["pda_theme"], pda_theme)
- READ_FILE(S["id_in_pda"], id_in_pda)
-
- READ_FILE(S["skillcape"], skillcape)
- READ_FILE(S["skillcape_id"], skillcape_id)
- READ_FILE(S["map"], map)
- READ_FILE(S["flare"], flare)
- READ_FILE(S["bar_choice"], bar_choice)
- READ_FILE(S["show_credits"], show_credits)
- READ_FILE(S["alternative_announcers"], disable_alternative_announcers)
- READ_FILE(S["balloon_alerts"], disable_balloon_alerts)
- READ_FILE(S["key_bindings"], key_bindings)
-
-
- // yogs start - Donor features
- READ_FILE(S["donor_pda"], donor_pda)
- READ_FILE(S["donor_hat"], donor_hat)
- READ_FILE(S["borg_hat"], borg_hat)
- READ_FILE(S["donor_item"], donor_item)
- READ_FILE(S["purrbation"], purrbation)
READ_FILE(S["yogtoggles"], yogtoggles)
+
+ READ_FILE(S["ignoring"], ignoring)
- READ_FILE(S["accent"], accent) // Accents, too!
-
- READ_FILE(S["mood_tail_wagging"], mood_tail_wagging)
- // yogs end
- check_keybindings()
+ READ_FILE(S["key_bindings"], key_bindings)
//try to fix any outdated data if necessary
if(needs_update >= 0)
update_preferences(needs_update, S) //needs_update = savefile_version if we need an update (positive integer)
+ // this apparently fails every time and overwrites any unloaded prefs with the default values, so don't load anything after this line or it won't actually save
+ check_keybindings()
+ key_bindings_by_key = get_key_bindings_by_key(key_bindings)
+
//Sanitize
- asaycolor = sanitize_ooccolor(sanitize_hexcolor(asaycolor, 6, 1, initial(asaycolor)))
- ooccolor = sanitize_ooccolor(sanitize_hexcolor(ooccolor, 6, 1, initial(ooccolor)))
- lastchangelog = sanitize_text(lastchangelog, initial(lastchangelog))
- UI_style = sanitize_inlist(UI_style, GLOB.available_ui_styles, GLOB.available_ui_styles[1])
- hotkeys = sanitize_integer(hotkeys, FALSE, TRUE, initial(hotkeys))
- chat_on_map = sanitize_integer(chat_on_map, FALSE, TRUE, initial(chat_on_map))
- max_chat_length = sanitize_integer(max_chat_length, 1, CHAT_MESSAGE_MAX_LENGTH, initial(max_chat_length))
- see_chat_non_mob = sanitize_integer(see_chat_non_mob, FALSE, TRUE, initial(see_chat_non_mob))
- see_rc_emotes = sanitize_integer(see_rc_emotes, FALSE, TRUE, initial(see_rc_emotes))
- tgui_fancy = sanitize_integer(tgui_fancy, FALSE, TRUE, initial(tgui_fancy))
- tgui_lock = sanitize_integer(tgui_lock, FALSE, TRUE, initial(tgui_lock))
- buttons_locked = sanitize_integer(buttons_locked, FALSE, TRUE, initial(buttons_locked))
- windowflashing = sanitize_integer(windowflashing, FALSE, TRUE, initial(windowflashing))
- default_slot = sanitize_integer(default_slot, 1, max_save_slots, initial(default_slot))
- toggles = sanitize_integer(toggles, 0, ~0, initial(toggles)) // Yogs -- Fixes toggles not having >16 bits of flagspace
- clientfps = sanitize_integer(clientfps, 0, 1000, 0)
- parallax = sanitize_integer(parallax, PARALLAX_INSANE, PARALLAX_DISABLE, null)
- ambientocclusion = sanitize_integer(ambientocclusion, FALSE, TRUE, initial(ambientocclusion))
- auto_fit_viewport = sanitize_integer(auto_fit_viewport, FALSE, TRUE, initial(auto_fit_viewport))
- widescreenpref = sanitize_integer(widescreenpref, FALSE, TRUE, initial(widescreenpref))
- pixel_size = sanitize_integer(pixel_size, PIXEL_SCALING_AUTO, PIXEL_SCALING_3X, 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)
- menuoptions = SANITIZE_LIST(menuoptions)
- be_special = SANITIZE_LIST(be_special)
- pda_style = sanitize_inlist(pda_style, GLOB.pda_styles, initial(pda_style))
- pda_color = sanitize_hexcolor(pda_color, 6, 1, initial(pda_color))
- pda_theme = sanitize_inlist(pda_theme, GLOB.pda_themes, initial(pda_theme))
- skillcape = sanitize_integer(skillcape, 1, 82, initial(skillcape))
- skillcape_id = sanitize_text(skillcape_id, initial(skillcape_id))
-
- if(skillcape_id != "None" && !(skillcape_id in GLOB.skillcapes))
- skillcape_id = "None"
-
- map = sanitize_integer(map, FALSE, TRUE, initial(map))
- flare = sanitize_integer(flare, FALSE, TRUE, initial(flare))
- bar_choice = sanitize_text(bar_choice, initial(bar_choice))
- disable_alternative_announcers = sanitize_integer(disable_alternative_announcers, FALSE, TRUE, initial(disable_alternative_announcers))
- disable_balloon_alerts = sanitize_integer(disable_balloon_alerts, FALSE, TRUE, initial(disable_balloon_alerts))
- key_bindings = sanitize_islist(key_bindings, list())
-
- var/bar_sanitize = FALSE
- for(var/A in GLOB.potential_box_bars)
- if(bar_choice == A)
- bar_sanitize = TRUE
- break
- if(!bar_sanitize)
- bar_choice = "Random"
- if(!player_alt_titles) player_alt_titles = new()
- show_credits = sanitize_integer(show_credits, FALSE, TRUE, initial(show_credits))
-
- // yogs start - Donor features & yogtoggles
- yogtoggles = sanitize_integer(yogtoggles, 0, (1 << 23), initial(yogtoggles))
- donor_pda = sanitize_integer(donor_pda, 1, GLOB.donor_pdas.len, 1)
- purrbation = sanitize_integer(purrbation, FALSE, TRUE, initial(purrbation))
-
- accent = sanitize_text(accent, initial(accent)) // Can't use sanitize_inlist since it doesn't support falsely default values.
- // yogs end
+ lastchangelog = sanitize_text(lastchangelog, initial(lastchangelog))
+ default_slot = sanitize_integer(default_slot, 1, max_save_slots, initial(default_slot))
+ toggles = sanitize_integer(toggles, 0, ~0, initial(toggles)) // Yogs -- Fixes toggles not having >16 bits of flagspace
+ be_special = sanitize_be_special(SANITIZE_LIST(be_special))
+ key_bindings = sanitize_keybindings(key_bindings)
return TRUE
@@ -363,79 +244,45 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
WRITE_FILE(S["version"] , SAVEFILE_VERSION_MAX) //updates (or failing that the sanity checks) will ensure data is not invalid at load. Assume up-to-date
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (preference.savefile_identifier != PREFERENCE_PLAYER)
+ continue
+
+ if (!(preference.type in recently_updated_keys))
+ continue
+
+ recently_updated_keys -= preference.type
+
+ if (preference_type in value_cache)
+ write_preference(preference, preference.serialize(value_cache[preference_type]))
+
//general preferences
- WRITE_FILE(S["asaycolor"], asaycolor)
- WRITE_FILE(S["ooccolor"], ooccolor)
WRITE_FILE(S["lastchangelog"], lastchangelog)
- WRITE_FILE(S["UI_style"], UI_style)
- WRITE_FILE(S["hotkeys"], hotkeys)
- WRITE_FILE(S["chat_on_map"], chat_on_map)
- WRITE_FILE(S["max_chat_length"], max_chat_length)
- WRITE_FILE(S["see_chat_non_mob"], see_chat_non_mob)
- WRITE_FILE(S["see_rc_emotes"], see_rc_emotes)
- WRITE_FILE(S["tgui_fancy"], tgui_fancy)
- WRITE_FILE(S["tgui_lock"], tgui_lock)
- WRITE_FILE(S["buttons_locked"], buttons_locked)
- WRITE_FILE(S["windowflash"], windowflashing)
WRITE_FILE(S["be_special"], be_special)
- WRITE_FILE(S["player_alt_titles"], player_alt_titles)
WRITE_FILE(S["default_slot"], default_slot)
+
WRITE_FILE(S["toggles"], toggles)
WRITE_FILE(S["chat_toggles"], chat_toggles)
WRITE_FILE(S["extra_toggles"], extra_toggles)
- WRITE_FILE(S["ghost_form"], ghost_form)
- WRITE_FILE(S["ghost_orbit"], ghost_orbit)
- WRITE_FILE(S["ghost_accs"], ghost_accs)
- WRITE_FILE(S["ghost_others"], ghost_others)
- WRITE_FILE(S["preferred_map"], preferred_map)
- WRITE_FILE(S["ignoring"], ignoring)
- WRITE_FILE(S["ghost_hud"], ghost_hud)
- WRITE_FILE(S["inquisitive_ghost"], inquisitive_ghost)
- WRITE_FILE(S["uses_glasses_colour"], uses_glasses_colour)
- WRITE_FILE(S["clientfps"], clientfps)
- WRITE_FILE(S["parallax"], parallax)
- WRITE_FILE(S["ambientocclusion"], ambientocclusion)
- WRITE_FILE(S["auto_fit_viewport"], auto_fit_viewport)
- WRITE_FILE(S["widescreenpref"], widescreenpref)
- WRITE_FILE(S["pixel_size"], pixel_size)
- WRITE_FILE(S["scaling_method"], scaling_method)
- WRITE_FILE(S["menuoptions"], menuoptions)
- WRITE_FILE(S["enable_tips"], enable_tips)
- WRITE_FILE(S["tip_delay"], tip_delay)
- WRITE_FILE(S["pda_style"], pda_style)
- WRITE_FILE(S["pda_color"], pda_color)
- WRITE_FILE(S["pda_theme"], pda_theme)
- WRITE_FILE(S["id_in_pda"], id_in_pda)
- WRITE_FILE(S["skillcape"], skillcape)
- WRITE_FILE(S["skillcape_id"], skillcape_id)
- WRITE_FILE(S["show_credits"], show_credits)
- WRITE_FILE(S["map"], map)
- WRITE_FILE(S["flare"], flare)
- WRITE_FILE(S["bar_choice"], bar_choice)
- WRITE_FILE(S["alternative_announcers"], disable_alternative_announcers)
- WRITE_FILE(S["balloon_alerts"], disable_balloon_alerts)
- WRITE_FILE(S["key_bindings"], key_bindings)
-
- // yogs start - Donor features & Yogstoggle
WRITE_FILE(S["yogtoggles"], yogtoggles)
- WRITE_FILE(S["donor_pda"], donor_pda)
- WRITE_FILE(S["donor_hat"], donor_hat)
- WRITE_FILE(S["borg_hat"], borg_hat)
- WRITE_FILE(S["donor_item"], donor_item)
- WRITE_FILE(S["purrbation"], purrbation)
- WRITE_FILE(S["accent"], accent) // Accents, too!
-
- WRITE_FILE(S["mood_tail_wagging"], mood_tail_wagging)
- // yogs end
+ WRITE_FILE(S["ignoring"], ignoring)
+
+ WRITE_FILE(S["key_bindings"], key_bindings)
return TRUE
/datum/preferences/proc/load_character(slot)
+ SHOULD_NOT_SLEEP(TRUE)
+
if(!path)
return FALSE
if(!fexists(path))
return FALSE
+
+ character_savefile = null
+
var/savefile/S = new /savefile(path)
if(!S)
return FALSE
@@ -451,93 +298,22 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
var/needs_update = savefile_needs_update(S)
if(needs_update == -2) //fatal, can't load any data
return FALSE
+
+ // Read everything into cache
+ for (var/preference_type in GLOB.preference_entries)
+ var/datum/preference/preference = GLOB.preference_entries[preference_type]
+ if (preference.savefile_identifier != PREFERENCE_CHARACTER)
+ continue
- //Species
- var/species_id
- READ_FILE(S["species"], species_id)
- if(species_id)
- var/newtype = GLOB.species_list[species_id]
- if(newtype)
- pref_species = new newtype
-
- if(!S["features["mcolor"]"] || S["features["mcolor"]"] == "#000")
- WRITE_FILE(S["features["mcolor"]"] , "#FFF")
-
- if(!S["feature_ethcolor"] || S["feature_ethcolor"] == "#000")
- WRITE_FILE(S["feature_ethcolor"] , "9c3030")
-
- if(!S["feature_pretcolor"] || S["feature_pretcolor"] == "#000")
- WRITE_FILE(S["feature_pretcolor"] , "9c3030")
+ value_cache -= preference_type
+ read_preference(preference_type)
//Character
- READ_FILE(S["real_name"], real_name)
- READ_FILE(S["name_is_always_random"], be_random_name)
- READ_FILE(S["body_is_always_random"], be_random_body)
- READ_FILE(S["gender"], gender)
- READ_FILE(S["age"], age)
- READ_FILE(S["hair_color"], hair_color)
- READ_FILE(S["facial_hair_color"], facial_hair_color)
- READ_FILE(S["eye_color"], eye_color)
- READ_FILE(S["skin_tone"], skin_tone)
- READ_FILE(S["hair_style_name"], hair_style)
- READ_FILE(S["facial_style_name"], facial_hair_style)
- READ_FILE(S["underwear"], underwear)
- READ_FILE(S["undershirt"], undershirt)
- READ_FILE(S["socks"], socks)
- READ_FILE(S["backbag"], backbag)
- READ_FILE(S["jumpsuit_style"], jumpsuit_style)
- READ_FILE(S["uplink_loc"], uplink_spawn_loc)
- READ_FILE(S["feature_mcolor"], features["mcolor"])
- READ_FILE(S["feature_gradientstyle"], features["gradientstyle"])
- READ_FILE(S["feature_gradientcolor"], features["gradientcolor"])
- READ_FILE(S["feature_ethcolor"], features["ethcolor"])
- READ_FILE(S["feature_pretcolor"], features["pretcolor"])
- READ_FILE(S["feature_lizard_tail"], features["tail_lizard"])
- READ_FILE(S["feature_lizard_snout"], features["snout"])
- READ_FILE(S["feature_lizard_horns"], features["horns"])
- READ_FILE(S["feature_lizard_frills"], features["frills"])
- READ_FILE(S["feature_lizard_spines"], features["spines"])
- READ_FILE(S["feature_lizard_body_markings"], features["body_markings"])
- READ_FILE(S["feature_lizard_legs"], features["legs"])
- READ_FILE(S["feature_moth_wings"], features["moth_wings"])
- READ_FILE(S["feature_polysmorph_tail"], features["tail_polysmorph"])
- READ_FILE(S["feature_polysmorph_teeth"], features["teeth"])
- READ_FILE(S["feature_polysmorph_dome"], features["dome"])
- READ_FILE(S["feature_polysmorph_dorsal_tubes"], features["dorsal_tubes"])
- READ_FILE(S["feature_ethereal_mark"], features["ethereal_mark"])
- READ_FILE(S["feature_pod_hair"], features["pod_hair"])
- READ_FILE(S["feature_pod_flower"], features["pod_flower"])
- READ_FILE(S["feature_ipc_screen"], features["ipc_screen"])
- READ_FILE(S["feature_ipc_antenna"], features["ipc_antenna"])
- READ_FILE(S["feature_ipc_chassis"], features["ipc_chassis"])
- READ_FILE(S["feature_plasmaman_helmet"], features["plasmaman_helmet"])
-
- READ_FILE(S["persistent_scars"], persistent_scars)
- if(!CONFIG_GET(flag/join_with_mutant_humans))
- features["tail_human"] = "none"
- features["ears"] = "none"
- else
- READ_FILE(S["feature_human_tail"], features["tail_human"])
- READ_FILE(S["feature_human_ears"], features["ears"])
-
- //Custom names
- for(var/custom_name_id in GLOB.preferences_custom_names)
- var/savefile_slot_name = custom_name_id + "_name" //TODO remove this
- READ_FILE(S[savefile_slot_name], custom_names[custom_name_id])
-
- READ_FILE(S["preferred_ai_core_display"], preferred_ai_core_display)
- READ_FILE(S["prefered_security_department"], prefered_security_department)
- READ_FILE(S["prefered_engineering_department"], prefered_engineering_department)
-
- //Jobs
- READ_FILE(S["joblessrole"], joblessrole)
- //Load prefs
+ READ_FILE(S["randomise"], randomise)
+ //Load prefs
READ_FILE(S["job_preferences"], job_preferences)
- if(!job_preferences)
- job_preferences = list()
-
//Quirks
READ_FILE(S["all_quirks"], all_quirks)
@@ -546,95 +322,22 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
update_character(needs_update, S) //needs_update == savefile_version if we need an update (positive integer)
//Sanitize
+ randomise = SANITIZE_LIST(randomise)
+ all_quirks = SANITIZE_LIST(all_quirks)
- real_name = reject_bad_name(real_name, pref_species.allow_numbers_in_name)
- gender = sanitize_gender(gender)
- if(!real_name)
- real_name = random_unique_name(gender)
-
- 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)]
-
- if(!features["pretcolor"] || features["pretcolor"] == "#000")
- features["pretcolor"] = GLOB.color_list_preternis[pick(GLOB.color_list_preternis)]
-
- 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))
-
- if(gender == MALE)
- hair_style = sanitize_inlist(hair_style, GLOB.hair_styles_male_list)
- facial_hair_style = sanitize_inlist(facial_hair_style, GLOB.facial_hair_styles_male_list)
- underwear = sanitize_inlist(underwear, GLOB.underwear_m)
- undershirt = sanitize_inlist(undershirt, GLOB.undershirt_m)
- else if(gender == FEMALE)
- hair_style = sanitize_inlist(hair_style, GLOB.hair_styles_female_list)
- facial_hair_style = sanitize_inlist(facial_hair_style, GLOB.facial_hair_styles_female_list)
- underwear = sanitize_inlist(underwear, GLOB.underwear_f)
- undershirt = sanitize_inlist(undershirt, GLOB.undershirt_f)
- else
- 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)
-
-
- 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)
- 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, initial(uplink_spawn_loc))
- features["mcolor"] = sanitize_hexcolor(features["mcolor"], 3, 0)
- features["gradientstyle"] = sanitize_inlist(features["gradientstyle"], GLOB.hair_gradients_list)
- features["gradientcolor"] = sanitize_hexcolor(features["gradientcolor"], 3, 0)
- features["ethcolor"] = copytext_char(features["ethcolor"], 1, 7)
- features["pretcolor"] = copytext_char(features["pretcolor"], 1, 7)
- features["tail_lizard"] = sanitize_inlist(features["tail_lizard"], GLOB.tails_list_lizard)
- features["tail_polysmorph"] = sanitize_inlist(features["tail_polysmorph"], GLOB.tails_list_polysmorph)
- 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_list, "Plain")
- features["teeth"] = sanitize_inlist(features["teeth"], GLOB.teeth_list)
- features["dome"] = sanitize_inlist(features["dome"], GLOB.dome_list)
- features["dorsal_tubes"] = sanitize_inlist(features["dorsal_tubes"], GLOB.dorsal_tubes_list)
- features["ethereal_mark"] = sanitize_inlist(features["ethereal_mark"], GLOB.ethereal_mark_list)
- features["pod_hair"] = sanitize_inlist(features["pod_hair"], GLOB.pod_hair_list)
- features["pod_flower"] = sanitize_inlist(features["pod_flower"], GLOB.pod_flower_list)
- 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)
-
- persistent_scars = sanitize_integer(persistent_scars)
-
- 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)
+ //all_quirks = SSquirks.filter_invalid_quirks(all_quirks, parent)
+ validate_quirks()
return TRUE
/datum/preferences/proc/save_character()
+ SHOULD_NOT_SLEEP(TRUE)
+
if(!path)
return FALSE
var/savefile/S = new /savefile(path)
@@ -642,75 +345,48 @@ SAVEFILE UPDATING/VERSIONING - 'Simplified', or rather, more coder-friendly ~Car
return FALSE
S.cd = "/character[default_slot]"
- WRITE_FILE(S["version"] , SAVEFILE_VERSION_MAX) //load_character will sanitize any bad data, so assume up-to-date.)
+ for (var/datum/preference/preference as anything in get_preferences_in_priority_order())
+ if (preference.savefile_identifier != PREFERENCE_CHARACTER)
+ continue
+
+ if (!(preference.type in recently_updated_keys))
+ continue
+
+ recently_updated_keys -= preference.type
+
+ if (preference.type in value_cache)
+ write_preference(preference, preference.serialize(value_cache[preference.type]))
+
+ WRITE_FILE(S["version"], SAVEFILE_VERSION_MAX) //load_character will sanitize any bad data, so assume up-to-date.)
//Character
- WRITE_FILE(S["real_name"] , real_name)
- WRITE_FILE(S["name_is_always_random"] , be_random_name)
- WRITE_FILE(S["body_is_always_random"] , be_random_body)
- WRITE_FILE(S["gender"] , gender)
- WRITE_FILE(S["age"] , age)
- WRITE_FILE(S["hair_color"] , hair_color)
- WRITE_FILE(S["facial_hair_color"] , facial_hair_color)
- WRITE_FILE(S["eye_color"] , eye_color)
- WRITE_FILE(S["skin_tone"] , skin_tone)
- WRITE_FILE(S["hair_style_name"] , hair_style)
- WRITE_FILE(S["facial_style_name"] , facial_hair_style)
- WRITE_FILE(S["underwear"] , underwear)
- WRITE_FILE(S["undershirt"] , undershirt)
- WRITE_FILE(S["socks"] , socks)
- WRITE_FILE(S["backbag"] , backbag)
- WRITE_FILE(S["jumpsuit_style"] , jumpsuit_style)
- WRITE_FILE(S["uplink_loc"] , uplink_spawn_loc)
- WRITE_FILE(S["species"] , pref_species.id)
- WRITE_FILE(S["feature_mcolor"] , features["mcolor"])
- WRITE_FILE(S["feature_gradientstyle"] , features["gradientstyle"])
- WRITE_FILE(S["feature_gradientcolor"] , features["gradientcolor"])
- WRITE_FILE(S["feature_ethcolor"] , features["ethcolor"])
- WRITE_FILE(S["feature_pretcolor"] , features["pretcolor"])
- WRITE_FILE(S["feature_lizard_tail"] , features["tail_lizard"])
- WRITE_FILE(S["feature_polysmorph_tail"] , features["tail_polysmorph"])
- WRITE_FILE(S["feature_human_tail"] , features["tail_human"])
- WRITE_FILE(S["feature_lizard_snout"] , features["snout"])
- WRITE_FILE(S["feature_lizard_horns"] , features["horns"])
- WRITE_FILE(S["feature_human_ears"] , features["ears"])
- WRITE_FILE(S["feature_lizard_frills"] , features["frills"])
- WRITE_FILE(S["feature_lizard_spines"] , features["spines"])
- WRITE_FILE(S["feature_lizard_body_markings"] , features["body_markings"])
- WRITE_FILE(S["feature_lizard_legs"] , features["legs"])
- WRITE_FILE(S["feature_moth_wings"] , features["moth_wings"])
- WRITE_FILE(S["feature_polysmorph_teeth"] , features["teeth"])
- WRITE_FILE(S["feature_polysmorph_dome"] , features["dome"])
- WRITE_FILE(S["feature_polysmorph_dorsal_tubes"] , features["dorsal_tubes"])
- WRITE_FILE(S["feature_ethereal_mark"] , features["ethereal_mark"])
- WRITE_FILE(S["feature_pod_hair"] , features["pod_hair"])
- WRITE_FILE(S["feature_pod_flower"] , features["pod_flower"])
- WRITE_FILE(S["persistent_scars"] , persistent_scars)
- WRITE_FILE(S["feature_ipc_screen"] , features["ipc_screen"])
- WRITE_FILE(S["feature_ipc_antenna"] , features["ipc_antenna"])
- WRITE_FILE(S["feature_ipc_chassis"] , features["ipc_chassis"])
- WRITE_FILE(S["feature_plasmaman_helmet"] , features["plasmaman_helmet"])
-
- //Custom names
- for(var/custom_name_id in GLOB.preferences_custom_names)
- var/savefile_slot_name = custom_name_id + "_name" //TODO remove this
- WRITE_FILE(S[savefile_slot_name],custom_names[custom_name_id])
-
- WRITE_FILE(S["preferred_ai_core_display"] , preferred_ai_core_display)
- WRITE_FILE(S["prefered_security_department"] , prefered_security_department)
- WRITE_FILE(S["prefered_engineering_department"] , prefered_engineering_department)
-
- //Jobs
- WRITE_FILE(S["joblessrole"] , joblessrole)
+ WRITE_FILE(S["randomise"] , randomise)
+
//Write prefs
WRITE_FILE(S["job_preferences"] , job_preferences)
//Quirks
- WRITE_FILE(S["all_quirks"] , all_quirks)
+ WRITE_FILE(S["all_quirks"] , all_quirks)
return TRUE
+/datum/preferences/proc/sanitize_be_special(list/input_be_special)
+ var/list/output = list()
+
+ for (var/role in input_be_special)
+ if (role in GLOB.special_roles)
+ output += role
+
+ return output.len == input_be_special.len ? input_be_special : output
+
+/proc/sanitize_keybindings(value)
+ var/list/base_bindings = sanitize_islist(value,list())
+ for(var/keybind_name in base_bindings)
+ if (!(keybind_name in GLOB.keybindings_by_name))
+ base_bindings -= keybind_name
+ return base_bindings
+
#undef SAVEFILE_VERSION_MAX
#undef SAVEFILE_VERSION_MIN
diff --git a/code/modules/client/preferences_toggles.dm b/code/modules/client/preferences_toggles.dm
deleted file mode 100644
index 9c0568a4ff0b..000000000000
--- a/code/modules/client/preferences_toggles.dm
+++ /dev/null
@@ -1,521 +0,0 @@
-//this works as is to create a single checked item, but has no back end code for toggleing the check yet
-#define TOGGLE_CHECKBOX(PARENT, CHILD) PARENT/CHILD/abstract = TRUE;PARENT/CHILD/checkbox = CHECKBOX_TOGGLE;PARENT/CHILD/verb/CHILD
-
-//Example usage TOGGLE_CHECKBOX(datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_ears)()
-
-//override because we don't want to save preferences twice.
-/datum/verbs/menu/Settings/Set_checked(client/C, verbpath)
- if (checkbox == CHECKBOX_GROUP)
- C.prefs.menuoptions[type] = verbpath
- else if (checkbox == CHECKBOX_TOGGLE)
- var/checked = Get_checked(C)
- C.prefs.menuoptions[type] = !checked
- winset(C, "[verbpath]", "is-checked = [!checked]")
-
-/datum/verbs/menu/Settings/verb/setup_character()
- set name = "Game Preferences"
- set category = "Preferences"
- set desc = "Open Game Preferences Window"
- usr.client.prefs.current_tab = 1
- usr.client.prefs.ShowChoices(usr)
-
-//toggles
-/datum/verbs/menu/Settings/Ghost/chatterbox
- name = "Chat Box Spam"
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_ears)()
- set name = "Show/Hide GhostEars"
- set category = "Preferences"
- set desc = "See All Speech"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTEARS
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTEARS) ? "see all speech in the world" : "only see speech from nearby mobs"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Ears", "[usr.client.prefs.chat_toggles & CHAT_GHOSTEARS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_ears/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTEARS
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_sight)()
- set name = "Show/Hide GhostSight"
- set category = "Preferences"
- set desc = "See All Emotes"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTSIGHT
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTSIGHT) ? "see all emotes in the world" : "only see emotes from nearby mobs"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Sight", "[usr.client.prefs.chat_toggles & CHAT_GHOSTSIGHT ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_sight/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTSIGHT
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_whispers)()
- set name = "Show/Hide GhostWhispers"
- set category = "Preferences"
- set desc = "See All Whispers"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTWHISPER
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTWHISPER) ? "see all whispers in the world" : "only see whispers from nearby mobs"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Whispers", "[usr.client.prefs.chat_toggles & CHAT_GHOSTWHISPER ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_whispers/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTWHISPER
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_radio)()
- set name = "Show/Hide GhostRadio"
- set category = "Preferences"
- set desc = "See All Radio Chatter"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTRADIO
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTRADIO) ? "see radio chatter" : "not see radio chatter"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Radio", "[usr.client.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
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_radio/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTRADIO
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox, toggle_ghost_pda)()
- set name = "Show/Hide GhostPDA"
- set category = "Preferences"
- set desc = "See All PDA Messages"
- usr.client.prefs.chat_toggles ^= CHAT_GHOSTPDA
- to_chat(usr, "As a ghost, you will now [(usr.client.prefs.chat_toggles & CHAT_GHOSTPDA) ? "see all pda messages in the world" : "only see pda messages from nearby mobs"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost PDA", "[usr.client.prefs.chat_toggles & CHAT_GHOSTPDA ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/chatterbox/toggle_ghost_pda/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_GHOSTPDA
-
-/datum/verbs/menu/Settings/Ghost/chatterbox/Events
- name = "Events"
-
-//please be aware that the following two verbs have inverted stat output, so that "Toggle Deathrattle|1" still means you activated it
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox/Events, toggle_deathrattle)()
- set name = "Toggle Deathrattle"
- set category = "Preferences"
- set desc = "Death"
- usr.client.prefs.toggles ^= DISABLE_DEATHRATTLE
- usr.client.prefs.save_preferences()
- to_chat(usr, "You will [(usr.client.prefs.toggles & DISABLE_DEATHRATTLE) ? "no longer" : "now"] get messages when a sentient mob dies.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Deathrattle", "[!(usr.client.prefs.toggles & DISABLE_DEATHRATTLE) ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, maybe you should spend some time reading the comments.
-/datum/verbs/menu/Settings/Ghost/chatterbox/Events/toggle_deathrattle/Get_checked(client/C)
- return !(C.prefs.toggles & DISABLE_DEATHRATTLE)
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost/chatterbox/Events, toggle_arrivalrattle)()
- set name = "Toggle Arrivalrattle"
- set category = "Preferences"
- set desc = "New Player Arrival"
- usr.client.prefs.toggles ^= DISABLE_ARRIVALRATTLE
- to_chat(usr, "You will [(usr.client.prefs.toggles & DISABLE_ARRIVALRATTLE) ? "no longer" : "now"] get messages when someone joins the station.")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Arrivalrattle", "[!(usr.client.prefs.toggles & DISABLE_ARRIVALRATTLE) ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, maybe you should rethink where your life went so wrong.
-/datum/verbs/menu/Settings/Ghost/chatterbox/Events/toggle_arrivalrattle/Get_checked(client/C)
- return !(C.prefs.toggles & DISABLE_ARRIVALRATTLE)
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Ghost, togglemidroundantag)()
- set name = "Toggle Midround Antagonist"
- set category = "Preferences"
- set desc = "Midround Antagonist"
- usr.client.prefs.toggles ^= MIDROUND_ANTAG
- usr.client.prefs.save_preferences()
- to_chat(usr, "You will [(usr.client.prefs.toggles & MIDROUND_ANTAG) ? "now" : "no longer"] be considered for midround antagonist positions.")
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Midround Antag", "[usr.client.prefs.toggles & MIDROUND_ANTAG ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Ghost/togglemidroundantag/Get_checked(client/C)
- return C.prefs.toggles & MIDROUND_ANTAG
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggletitlemusic)()
- set name = "Hear/Silence Lobby Music"
- set category = "Preferences"
- set desc = "Hear Music In Lobby"
- usr.client.prefs.toggles ^= SOUND_LOBBY
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & SOUND_LOBBY)
- to_chat(usr, "You will now hear music in the game lobby.")
- if(isnewplayer(usr))
- usr.client.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", "[usr.client.prefs.toggles & SOUND_LOBBY ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggletitlemusic/Get_checked(client/C)
- return C.prefs.toggles & SOUND_LOBBY
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, togglemidis)()
- set name = "Hear/Silence Midis"
- set category = "Preferences"
- set desc = "Hear Admin Triggered Sounds (Midis)"
- usr.client.prefs.toggles ^= SOUND_MIDI
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & 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", "[usr.client.prefs.toggles & SOUND_MIDI ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/togglemidis/Get_checked(client/C)
- return C.prefs.toggles & SOUND_MIDI
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_instruments)()
- set name = "Hear/Silence Instruments"
- set category = "Preferences"
- set desc = "Hear In-game Instruments"
- usr.client.prefs.toggles ^= SOUND_INSTRUMENTS
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & 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", "[usr.client.prefs.toggles & SOUND_INSTRUMENTS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_instruments/Get_checked(client/C)
- return C.prefs.toggles & SOUND_INSTRUMENTS
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_jukebox)()
- set name = "Hear/Silence Jukeboxes"
- set category = "Preferences"
- set desc = "Hear In-game Jukeboxes"
- usr.client.prefs.toggles ^= SOUND_JUKEBOX
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & SOUND_JUKEBOX)
- to_chat(usr, "You will now hear jukeboxes.")
- else
- to_chat(usr, "You will no longer hear jukeboxes.")
- usr.client.update_playing_music()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Jukeboxes", "[usr.client.prefs.toggles & SOUND_JUKEBOX ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_jukebox/Get_checked(client/C)
- return C.prefs.toggles & SOUND_JUKEBOX
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, Toggle_Soundscape)()
- set name = "Hear/Silence Ambience"
- set category = "Preferences"
- set desc = "Hear Ambient Sound Effects"
- usr.client.prefs.toggles ^= SOUND_AMBIENCE
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & 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_AMBIENCE)
- usr.stop_sound_channel(CHANNEL_BUZZ)
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ambience", "[usr.client.prefs.toggles & SOUND_AMBIENCE ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/Toggle_Soundscape/Get_checked(client/C)
- return C.prefs.toggles & SOUND_AMBIENCE
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_ship_ambience)()
- set name = "Hear/Silence Ship Ambience"
- set category = "Preferences"
- set desc = "Hear Ship Ambience Roar"
- usr.client.prefs.toggles ^= SOUND_SHIP_AMBIENCE
- usr.client.prefs.save_preferences()
- if(usr.client.prefs.toggles & 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.ambience_playing = 0
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ship Ambience", "[usr.client.prefs.toggles & SOUND_SHIP_AMBIENCE ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, I bet you read this comment expecting to see the same thing :^)
-/datum/verbs/menu/Settings/Sound/toggle_ship_ambience/Get_checked(client/C)
- return C.prefs.toggles & SOUND_SHIP_AMBIENCE
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_announcement_sound)()
- set name = "Hear/Silence Announcements"
- set category = "Preferences"
- set desc = "Hear Announcement Sound"
- usr.client.prefs.toggles ^= SOUND_ANNOUNCEMENTS
- to_chat(usr, "You will now [(usr.client.prefs.toggles & SOUND_ANNOUNCEMENTS) ? "hear announcement sounds" : "no longer hear announcements"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Announcement Sound", "[usr.client.prefs.toggles & SOUND_ANNOUNCEMENTS ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_announcement_sound/Get_checked(client/C)
- return C.prefs.toggles & SOUND_ANNOUNCEMENTS
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_vox)()
- set name = "Hear/Silence VOX"
- set category = "Preferences"
- set desc = "Hear VOX Announcements"
- usr.client.prefs.toggles ^= SOUND_VOX
- to_chat(usr, "You will now [(usr.client.prefs.toggles & SOUND_VOX) ? "hear VOX announcements" : "no longer hear VOX announcements"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle VOX", "[usr.client.prefs.toggles & SOUND_VOX ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_vox/Get_checked(client/C)
- return C.prefs.toggles & SOUND_VOX
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings/Sound, toggle_alt)()
- set name = "Hear/Silence Alternative Sounds"
- set category = "Preferences"
- set desc = "Hear potentially annoying \"alternative\" sounds"
- usr.client.prefs.toggles ^= SOUND_ALT
- to_chat(usr, "You will now [(usr.client.prefs.toggles & SOUND_ALT) ? "hear alternative sounds" : "no longer hear alternative sounds"].")
- usr.client.prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Alternative Sounds", "[usr.client.prefs.toggles & SOUND_ALT ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/Sound/toggle_alt/Get_checked(client/C)
- return C.prefs.toggles & SOUND_ALT
-
-/datum/verbs/menu/Settings/Sound/verb/stop_client_sounds()
- set name = "Stop Sounds"
- set category = "Preferences"
- set desc = "Stop Current Sounds"
- SEND_SOUND(usr, sound(null))
- var/client/C = usr.client
- C?.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!
-
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings, listen_ooc)()
- set name = "Show/Hide OOC"
- set category = "Preferences"
- set desc = "Show OOC Chat"
- usr.client.prefs.chat_toggles ^= CHAT_OOC
- usr.client.prefs.save_preferences()
- to_chat(usr, "You will [(usr.client.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", "[usr.client.prefs.chat_toggles & CHAT_OOC ? "Enabled" : "Disabled"]")) //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc!
-/datum/verbs/menu/Settings/listen_ooc/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_OOC
-
-TOGGLE_CHECKBOX(/datum/verbs/menu/Settings, listen_bank_card)()
- set name = "Show/Hide Income Updates"
- set category = "Preferences"
- set desc = "Show or hide updates to your income"
- usr.client.prefs.chat_toggles ^= CHAT_BANKCARD
- usr.client.prefs.save_preferences()
- to_chat(usr, "You will [(usr.client.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", "[(usr.client.prefs.chat_toggles & CHAT_BANKCARD) ? "Enabled" : "Disabled"]"))
-/datum/verbs/menu/Settings/listen_bank_card/Get_checked(client/C)
- return C.prefs.chat_toggles & CHAT_BANKCARD
-
-
-GLOBAL_LIST_INIT(ghost_forms, 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())
- tgui_alert(usr,"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)
-
-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())
- tgui_alert(usr,"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 = tgui_alert(usr,"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?",,list("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(tgui_alert(usr,"Which setting do you want to change?",,list("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 = tgui_alert(usr,"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?",,list("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 ^= INTENT_STYLE
- to_chat(src, "[(prefs.toggles & 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 & 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.ghost_hud = !prefs.ghost_hud
- to_chat(src, "Ghost HUD will now be [prefs.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.ghost_hud ? "Enabled" : "Disabled"]"))
-
-/client/verb/toggle_show_credits()
- set name = "Toggle Credits"
- set category = "Preferences"
- set desc = "Hide/Show Credits"
-
- prefs.show_credits = !prefs.show_credits
- to_chat(src, "Credits will now be [prefs.show_credits ? "visible" : "hidden"].")
- prefs.save_preferences()
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Credits", "[prefs.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.inquisitive_ghost = !prefs.inquisitive_ghost
- prefs.save_preferences()
- if(prefs.inquisitive_ghost)
- to_chat(src, span_notice("You will now examine everything you click on."))
- else
- to_chat(src, span_notice("You will no longer examine things you click on."))
- SSblackbox.record_feedback("nested tally", "preferences_verb", 1, list("Toggle Ghost Inquisitiveness", "[prefs.inquisitive_ghost ? "Enabled" : "Disabled"]"))
-
-//Admin Preferences
-/client/proc/toggleadminhelpsound()
- set name = "Hear/Silence Adminhelps"
- set category = "Preferences.Admin"
- set desc = "Toggle hearing a notification when admin PMs are received"
- if(!holder)
- return
- prefs.toggles ^= SOUND_ADMINHELP
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & 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 & SOUND_ADMINHELP ? "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 = "Preferences.Admin"
- set desc = "Toggle if you want an announcement to admins when you login during a round"
- if(!holder)
- return
- prefs.toggles ^= ANNOUNCE_LOGIN
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & 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 & 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 = "Preferences.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 = "Preferences.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 = "Preferences.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 = "Preferences.Admin"
- set desc = "Hear Prayer Sounds"
- if(!holder)
- return
- prefs.toggles ^= SOUND_PRAYERS
- prefs.save_preferences()
- to_chat(usr, "You will [(prefs.toggles & SOUND_PRAYERS) ? "now" : "no longer"] hear a sound when prayers arrive.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Prayer Sounds", "[usr.client.prefs.toggles & SOUND_PRAYERS ? "Enabled" : "Disabled"]"))
-
-/client/proc/colorasay()
- set name = "Set Admin Say Color"
- set category = "Preferences.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 = input(src, "Please select your ASAY color.", "ASAY color", prefs.asaycolor) as color|null
- 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 = "Preferences.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()
-
-/client/proc/toggle_split_admin_tabs()
- set name = "Toggle Split Admin Tabs"
- set category = "Preferences.Admin"
- set desc = "Toggle the admin tab being split into separate tabs instead of being merged into one"
- if(!holder)
- return
- prefs.extra_toggles ^= SPLIT_ADMIN_TABS
- prefs.save_preferences()
- to_chat(src, "Admin tabs will now [(prefs.extra_toggles & SPLIT_ADMIN_TABS) ? "be" : "not be"] split.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Split Admin Tabs", "[prefs.extra_toggles & SPLIT_ADMIN_TABS ? "Enabled" : "Disabled"]"))
-
-/client/proc/toggle_fast_mc_refresh()
- set name = "Toggle Fast MC Refresh"
- set category = "Preferences.Admin"
- set desc = "Toggle the speed of which the MC refreshes."
- if(!holder)
- return
- prefs.extra_toggles ^= FAST_MC_REFRESH
- prefs.save_preferences()
- to_chat(src, "MC will now [(prefs.extra_toggles & FAST_MC_REFRESH) ? "not be" : "be"] fast updating.")
- SSblackbox.record_feedback("nested tally", "admin_toggle", 1, list("Toggle Fast MC Refresh", "[prefs.extra_toggles & FAST_MC_REFRESH ? "Enabled" : "Disabled"]"))
diff --git a/code/modules/client/verbs/etips.dm b/code/modules/client/verbs/etips.dm
deleted file mode 100644
index 5c53deb9a3de..000000000000
--- 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.enable_tips = !prefs.enable_tips
- prefs.save_preferences()
- to_chat(usr, span_danger("Examine tooltips [prefs.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, span_danger("Tooltip delay set to [indelay] milliseconds."))
diff --git a/code/modules/client/verbs/ooc.dm b/code/modules/client/verbs/ooc.dm
index be70c608d03f..ab69f1e9c4fe 100644
--- a/code/modules/client/verbs/ooc.dm
+++ b/code/modules/client/verbs/ooc.dm
@@ -69,7 +69,7 @@ GLOBAL_VAR_INIT(mentor_ooc_colour, YOGS_MENTOR_OOC_COLOUR) // yogs - mentor ooc
var/keyname = key
if(prefs.unlock_content)
if(prefs.toggles & MEMBER_PUBLIC)
- keyname = "[icon2html('icons/member_content.dmi', world, "blag")][keyname] "
+ keyname = "[icon2html('icons/member_content.dmi', world, "blag")][keyname] "
//YOG START - Yog OOC
//PINGS
@@ -100,7 +100,8 @@ GLOBAL_VAR_INIT(mentor_ooc_colour, YOGS_MENTOR_OOC_COLOUR) // yogs - mentor ooc
var/oocmsg_toadmins = FALSE; // The message sent to admins.
if(holder) // If the speaker is an admin or something
if(check_rights_for(src, R_ADMIN)) // If they're supposed to have their own admin OOC colour
- oocmsg += "[(CONFIG_GET(flag/allow_admin_ooccolor) && prefs.ooccolor) ? "" :"" ][find_admin_rank(src)]" // The header for an Admin's OOC.
+ var/ooc_color = prefs.read_preference(/datum/preference/color/ooc_color)
+ oocmsg += "[(CONFIG_GET(flag/allow_admin_ooccolor) && ooc_color) ? "" :"" ][find_admin_rank(src)]" // The header for an Admin's OOC.
else // Else if they're an AdminObserver
oocmsg += "[find_admin_rank(src)]" // The header for an AO's OOC.
//Check yogstation\code\module\client\verbs\ooc for the find_admin_rank definition.
@@ -119,7 +120,7 @@ GLOBAL_VAR_INIT(mentor_ooc_colour, YOGS_MENTOR_OOC_COLOUR) // yogs - mentor ooc
mposition = src.mentor_datum?.position
oocmsg = "\["
oocmsg += "[mposition]"
- oocmsg += "]"
+ oocmsg += "]"
else
oocmsg = "[(is_donator(src) && !CONFIG_GET(flag/everyone_is_donator)) ? "(Donator)" : ""]"
oocmsg += ""
@@ -176,7 +177,7 @@ GLOBAL_VAR_INIT(mentor_ooc_colour, YOGS_MENTOR_OOC_COLOUR) // yogs - mentor ooc
set name = "Set Player OOC Color"
set desc = "Modifies player OOC Color"
set category = "Server"
- GLOB.OOC_COLOR = sanitize_ooccolor(newColor)
+ GLOB.OOC_COLOR = sanitize_color(newColor)
/client/proc/reset_ooc()
set name = "Reset Player OOC Color"
@@ -194,33 +195,6 @@ GLOBAL_VAR_INIT(mentor_ooc_colour, YOGS_MENTOR_OOC_COLOUR) // yogs - mentor ooc
log_admin("[key_name_admin(usr)] has reset player ooc color.")
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 = input(src, "Please select your OOC color.", "OOC color", prefs.ooccolor) as color|null
- 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"
diff --git a/code/modules/clothing/glasses/_glasses.dm b/code/modules/clothing/glasses/_glasses.dm
index 9e4fb396d564..7789a74dfd3d 100644
--- a/code/modules/clothing/glasses/_glasses.dm
+++ b/code/modules/clothing/glasses/_glasses.dm
@@ -545,16 +545,18 @@
/obj/item/clothing/glasses/AltClick(mob/user)
if(glass_colour_type && ishuman(user))
- var/mob/living/carbon/human/H = user
- if(H.client)
- if(H.client.prefs)
- if(src == H.glasses)
- H.client.prefs.uses_glasses_colour = !H.client.prefs.uses_glasses_colour
- if(H.client.prefs.uses_glasses_colour)
- 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)
+ var/mob/living/carbon/human/human_user = user
+
+ if (human_user.glasses != src)
+ return ..()
+
+ if (HAS_TRAIT_FROM(human_user, TRAIT_SEE_GLASS_COLORS, GLASSES_TRAIT))
+ REMOVE_TRAIT(human_user, TRAIT_SEE_GLASS_COLORS, GLASSES_TRAIT)
+ to_chat(human_user, span_notice("You will now see glasses colors."))
+ else
+ ADD_TRAIT(human_user, TRAIT_SEE_GLASS_COLORS, GLASSES_TRAIT)
+ to_chat(human_user, span_notice("You will no longer see glasses colors."))
+ human_user.update_glasses_color(src, TRUE)
else
return ..()
@@ -570,7 +572,7 @@
/mob/living/carbon/human/proc/update_glasses_color(obj/item/clothing/glasses/G, glasses_equipped)
- if(client && client.prefs.uses_glasses_colour && glasses_equipped)
+ if (HAS_TRAIT(src, TRAIT_SEE_GLASS_COLORS) && glasses_equipped)
add_client_colour(G.glass_colour_type)
else
remove_client_colour(G.glass_colour_type)
diff --git a/code/modules/clothing/outfits/standard.dm b/code/modules/clothing/outfits/standard.dm
index 70feb1afced8..aa5b1b7f5887 100644
--- a/code/modules/clothing/outfits/standard.dm
+++ b/code/modules/clothing/outfits/standard.dm
@@ -90,8 +90,6 @@
uniform = /obj/item/clothing/under/pirate/space
suit = /obj/item/clothing/suit/space/pirate
head = /obj/item/clothing/head/helmet/space/pirate/bandana
- mask = /obj/item/clothing/mask/breath
- suit_store = /obj/item/tank/internals/oxygen
ears = /obj/item/radio/headset/syndicate
id = /obj/item/card/id
diff --git a/code/modules/clothing/spacesuits/hardsuit.dm b/code/modules/clothing/spacesuits/hardsuit.dm
index 4c0e2ec2bfd6..6e04b7388d46 100644
--- a/code/modules/clothing/spacesuits/hardsuit.dm
+++ b/code/modules/clothing/spacesuits/hardsuit.dm
@@ -61,6 +61,8 @@
else
qdel(src)
else
+ if(isdummy(user))
+ return
soundloop.start(user)
/obj/item/clothing/head/helmet/space/hardsuit/proc/display_visor_message(var/msg)
@@ -148,7 +150,7 @@
/obj/item/clothing/suit/space/hardsuit/equipped(mob/user, slot)
..()
- if(jetpack)
+ if(jetpack && istype(jetpack))
if(slot == SLOT_WEAR_SUIT)
for(var/X in jetpack.actions)
var/datum/action/A = X
@@ -156,7 +158,7 @@
/obj/item/clothing/suit/space/hardsuit/dropped(mob/user)
..()
- if(jetpack)
+ if(jetpack && istype(jetpack))
for(var/X in jetpack.actions)
var/datum/action/A = X
A.Remove(user)
@@ -384,7 +386,7 @@
name = "elite syndicate hardsuit helmet"
desc = "An elite version of the syndicate helmet, with improved armour and fireproofing. It is in EVA mode. Property of Gorlex Marauders."
alt_desc = "An elite version of the syndicate helmet, with improved armour and fireproofing. It is in combat mode. Property of Gorlex Marauders."
- icon_state = "hardsuit0-syndielite"
+ icon_state = "hardsuit1-syndielite"
hardsuit_type = "syndielite"
armor = list(MELEE = 60, BULLET = 60, LASER = 50, ENERGY = 35, BOMB = 90, BIO = 100, RAD = 70, FIRE = 100, ACID = 100, WOUND = 25)
heat_protection = HEAD
@@ -395,7 +397,7 @@
name = "elite syndicate hardsuit"
desc = "An elite version of the syndicate hardsuit, with improved armour and fireproofing. It is in travel mode."
alt_desc = "An elite version of the syndicate hardsuit, with improved armour and fireproofing. It is in combat mode."
- icon_state = "hardsuit0-syndielite"
+ icon_state = "hardsuit1-syndielite"
hardsuit_type = "syndielite"
helmettype = /obj/item/clothing/head/helmet/space/hardsuit/syndi/elite
armor = list(MELEE = 60, BULLET = 60, LASER = 50, ENERGY = 25, BOMB = 90, BIO = 100, RAD = 70, FIRE = 100, ACID = 100, WOUND = 25)
diff --git a/code/modules/clothing/spacesuits/plasmamen.dm b/code/modules/clothing/spacesuits/plasmamen.dm
index a45d14f6d2a3..e5661390a09c 100644
--- a/code/modules/clothing/spacesuits/plasmamen.dm
+++ b/code/modules/clothing/spacesuits/plasmamen.dm
@@ -78,11 +78,14 @@
if(!ishuman(user))
return
var/style = user.dna?.features["plasmaman_helmet"]
+ var/suffix = ""
if(style && (style in GLOB.plasmaman_helmet_list) && style != "None")
- icon_state = initial(icon_state) + "-[GLOB.plasmaman_helmet_list[style]]"
- item_state = icon_state
- base_icon_state = icon_state
- user.update_inv_head()
+ suffix = "-[GLOB.plasmaman_helmet_list[style]]"
+
+ icon_state = initial(icon_state) + suffix
+ item_state = icon_state
+ base_icon_state = icon_state
+ user.update_inv_head()
/obj/item/clothing/head/helmet/space/plasmaman/security
name = "security envirosuit helmet"
diff --git a/code/modules/clothing/suits/toggles.dm b/code/modules/clothing/suits/toggles.dm
index 306f4909e026..9eca99872dc3 100644
--- a/code/modules/clothing/suits/toggles.dm
+++ b/code/modules/clothing/suits/toggles.dm
@@ -133,7 +133,8 @@
if(helmet)
helmet.suit = null
qdel(helmet)
- qdel(jetpack)
+ if(jetpack && istype(jetpack))
+ qdel(jetpack)
return ..()
/obj/item/clothing/head/helmet/space/hardsuit/Destroy()
diff --git a/code/modules/events/devil.dm b/code/modules/events/devil.dm
index 3760cbe05d9a..ae01b40b8726 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/preferences/A = new() //Randomize appearance for the devil.
- A.copy_to(new_devil)
+ new_devil.randomize_human_appearance(~(RANDOMIZE_SPECIES))
new_devil.dna.update_dna_identity()
return new_devil
diff --git a/code/modules/events/operative.dm b/code/modules/events/operative.dm
index b5bba1902945..6120bccab6e6 100644
--- a/code/modules/events/operative.dm
+++ b/code/modules/events/operative.dm
@@ -24,8 +24,7 @@
return MAP_ERROR
var/mob/living/carbon/human/operative = new(pick(spawn_locs))
- var/datum/preferences/A = new
- A.copy_to(operative)
+ 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/events/sinfuldemon.dm b/code/modules/events/sinfuldemon.dm
index 9a916fb03e37..f1b798816f8b 100644
--- a/code/modules/events/sinfuldemon.dm
+++ b/code/modules/events/sinfuldemon.dm
@@ -58,8 +58,7 @@
var/mob/living/carbon/human/new_sinfuldemon = new(spawn_loc)
if(!spawn_loc)
SSjob.SendToLateJoin(new_sinfuldemon)
- var/datum/preferences/A = new() //Randomize appearance for the demon.
- A.copy_to(new_sinfuldemon)
+ new_sinfuldemon.randomize_human_appearance(~(RANDOMIZE_SPECIES))
new_sinfuldemon.dna.update_dna_identity()
return new_sinfuldemon
diff --git a/code/modules/events/tzimisce.dm b/code/modules/events/tzimisce.dm
index 1922d8031c20..1a2f5e60c560 100644
--- a/code/modules/events/tzimisce.dm
+++ b/code/modules/events/tzimisce.dm
@@ -84,8 +84,7 @@
/datum/round_event/ghost_role/tzimisce/proc/spawn_event_tzimisce()
var/mob/living/carbon/human/new_tzimisce = new()
SSjob.SendToLateJoin(new_tzimisce)
- var/datum/preferences/A = new() //Randomize appearance.
- A.copy_to(new_tzimisce)
+ new_tzimisce.randomize_human_appearance(~(RANDOMIZE_SPECIES))
new_tzimisce.dna.update_dna_identity()
return new_tzimisce
diff --git a/code/modules/flufftext/Hallucination.dm b/code/modules/flufftext/Hallucination.dm
index 7098ae845e64..cf1944184103 100644
--- a/code/modules/flufftext/Hallucination.dm
+++ b/code/modules/flufftext/Hallucination.dm
@@ -709,10 +709,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.chat_on_map)
+ if (!is_radio && !target.client?.prefs.read_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, /proc/flick_overlay, speech_overlay, list(target.client), 30)
- if (target.client?.prefs.chat_on_map)
+ if (target.client?.prefs.read_preference(/datum/preference/toggle/enable_runechat))
target.create_chat_message(person, understood_language, chosen, spans)
to_chat(target, message)
qdel(src)
@@ -884,7 +884,7 @@ GLOBAL_LIST_INIT(hallucination_list, list(
if("blob alert")
to_chat(target, "Biohazard Alert ")
to_chat(target, " [span_alert("Confirmed outbreak of level 5 biohazard aboard [station_name()]. All personnel must contain the outbreak.")] ")
- if(target.client.prefs.disable_alternative_announcers)
+ if(target.client.prefs.read_preference(/datum/preference/toggle/disable_alternative_announcers))
SEND_SOUND(target, SSstation.default_announcer.event_sounds[ANNOUNCER_OUTBREAK5])
else
SEND_SOUND(target, SSstation.announcer.event_sounds[ANNOUNCER_OUTBREAK5])
@@ -896,21 +896,21 @@ GLOBAL_LIST_INIT(hallucination_list, list(
if("shuttle dock")
to_chat(target, "Priority Announcement ")
to_chat(target, " [span_alert("The Emergency Shuttle has docked with the station. You have 3 minutes to board the Emergency Shuttle.")] ")
- if(target.client.prefs.disable_alternative_announcers)
+ if(target.client.prefs.read_preference(/datum/preference/toggle/disable_alternative_announcers))
SEND_SOUND(target, SSstation.default_announcer.event_sounds[ANNOUNCER_SHUTTLEDOCK])
else
SEND_SOUND(target, SSstation.announcer.event_sounds[ANNOUNCER_SHUTTLEDOCK])
if("malf ai") //AI is doomsdaying!
to_chat(target, "Anomaly Alert ")
to_chat(target, " [span_alert("Hostile runtimes detected in all station systems, please deactivate your AI to prevent possible damage to its morality core.")] ")
- if(target.client.prefs.disable_alternative_announcers)
+ if(target.client.prefs.read_preference(/datum/preference/toggle/disable_alternative_announcers))
SEND_SOUND(target, SSstation.default_announcer.event_sounds[ANNOUNCER_AIMALF])
else
SEND_SOUND(target, SSstation.announcer.event_sounds[ANNOUNCER_AIMALF])
if("meteors") //Meteors inbound!
to_chat(target, "Meteor Alert ")
to_chat(target, " [span_alert("Meteors have been detected on collision course with the station.")] ")
- if(target.client.prefs.disable_alternative_announcers)
+ if(target.client.prefs.read_preference(/datum/preference/toggle/disable_alternative_announcers))
SEND_SOUND(target, SSstation.default_announcer.event_sounds[ANNOUNCER_OUTBREAK5])
else
SEND_SOUND(target, SSstation.announcer.event_sounds[ANNOUNCER_OUTBREAK5])
diff --git a/code/modules/jobs/departments/departments.dm b/code/modules/jobs/departments/departments.dm
new file mode 100644
index 000000000000..a629c2eaab62
--- /dev/null
+++ b/code/modules/jobs/departments/departments.dm
@@ -0,0 +1,121 @@
+/// Singleton representing a category of jobs forming a department.
+/// NOTICE: This is NOT fully implemented everywhere. Currently only used in: Preferences menu
+/datum/job_department
+ /// Department as displayed on different menus.
+ var/department_name = DEPARTMENT_UNASSIGNED
+ /// Bitflags associated to the specific department.
+ var/department_bitflags = NONE
+ /// Typepath of the job datum leading this department.
+ var/datum/job/department_head = null
+ /// Experience granted by playing in a job of this department.
+ var/department_experience_type = null
+ /// The order in which this department appears on menus, in relation to other departments.
+ var/display_order = 0
+ /// The header color to be displayed in the ban panel, classes defined in banpanel.css
+ var/label_class = "undefineddepartment"
+ /// The color used in TGUI or similar menus.
+ var/ui_color = "#9689db"
+ /// Job singleton datums associated to this department. Populated on job initialization.
+ var/list/department_jobs = list()
+
+
+/// Handles adding jobs to the department and setting up the job bitflags.
+/datum/job_department/proc/add_job(datum/job/job)
+ department_jobs += job
+ job.departments_bitflags |= department_bitflags
+
+/// A special assistant only department, primarily for use by the preferences menu
+/datum/job_department/assistant
+ department_name = DEPARTMENT_ASSISTANT
+ department_bitflags = DEPARTMENT_BITFLAG_ASSISTANT
+ // Don't add department_head! Assistants names should not be in bold.
+
+/// A special captain only department, for use by the preferences menu
+/datum/job_department/captain
+ department_name = DEPARTMENT_CAPTAIN
+ department_bitflags = DEPARTMENT_BITFLAG_CAPTAIN
+ department_head = /datum/job/captain
+
+/datum/job_department/command
+ department_name = DEPARTMENT_COMMAND
+ department_bitflags = DEPARTMENT_BITFLAG_COMMAND
+ department_head = /datum/job/captain
+ department_experience_type = EXP_TYPE_COMMAND
+ display_order = 1
+ label_class = "command"
+ ui_color = "#ccccff"
+
+
+/datum/job_department/security
+ department_name = DEPARTMENT_SECURITY
+ department_bitflags = DEPARTMENT_BITFLAG_SECURITY
+ department_head = /datum/job/hos
+ department_experience_type = EXP_TYPE_SECURITY
+ display_order = 2
+ label_class = "security"
+ ui_color = "#ffbbbb"
+
+
+/datum/job_department/engineering
+ department_name = DEPARTMENT_ENGINEERING
+ department_bitflags = DEPARTMENT_BITFLAG_ENGINEERING
+ department_head = /datum/job/chief_engineer
+ department_experience_type = EXP_TYPE_ENGINEERING
+ display_order = 3
+ label_class = "engineering"
+ ui_color = "#ffeeaa"
+
+
+/datum/job_department/medical
+ department_name = DEPARTMENT_MEDICAL
+ department_bitflags = DEPARTMENT_BITFLAG_MEDICAL
+ department_head = /datum/job/cmo
+ department_experience_type = EXP_TYPE_MEDICAL
+ display_order = 4
+ label_class = "medical"
+ ui_color = "#c1e1ec"
+
+
+/datum/job_department/science
+ department_name = DEPARTMENT_SCIENCE
+ department_bitflags = DEPARTMENT_BITFLAG_SCIENCE
+ department_head = /datum/job/rd
+ department_experience_type = EXP_TYPE_SCIENCE
+ display_order = 5
+ label_class = "science"
+ ui_color = "#ffddff"
+
+
+/datum/job_department/cargo
+ department_name = DEPARTMENT_CARGO
+ department_bitflags = DEPARTMENT_BITFLAG_CARGO
+ department_head = /datum/job/qm
+ department_experience_type = EXP_TYPE_SUPPLY
+ display_order = 6
+ label_class = "supply"
+ ui_color = "#d7b088"
+
+
+/datum/job_department/service
+ department_name = DEPARTMENT_SERVICE
+ department_bitflags = DEPARTMENT_BITFLAG_SERVICE
+ department_head = /datum/job/hop
+ department_experience_type = EXP_TYPE_SERVICE
+ display_order = 7
+ label_class = "service"
+ ui_color = "#ddddff"
+
+
+/datum/job_department/silicon
+ department_name = DEPARTMENT_SILICON
+ department_bitflags = DEPARTMENT_BITFLAG_SILICON
+ department_head = /datum/job/ai
+ department_experience_type = EXP_TYPE_SILICON
+ display_order = 8
+ label_class = "silicon"
+ ui_color = "#ccffcc"
+
+
+/// Catch-all department for undefined jobs.
+/datum/job_department/undefined
+ display_order = 10
diff --git a/code/modules/jobs/job_types/_job.dm b/code/modules/jobs/job_types/_job.dm
index 938451ceed6e..225f75c1cbc6 100644
--- a/code/modules/jobs/job_types/_job.dm
+++ b/code/modules/jobs/job_types/_job.dm
@@ -1,20 +1,42 @@
/datum/job
/// The name of the job used for preferences, bans, etc.
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
+
/// This job comes with these accesses by default
var/list/base_access = list()
+
/// Additional accesses for the job if config.jobs_have_minimal_access is set to false
var/list/added_access = list()
+
/// Who is responsible for demoting them
var/department_head = list()
+
/// Tells the given channels that the given mob is the new department head. See communications.dm for valid channels.
var/list/head_announce = null
+
// Used for something in preferences_savefile.dm
+ // NOTE: currently unused
var/department_flag = NONE
+
+ /// Bitfield of departments this job belongs to. These get setup when adding the job into the department, on job datum creation.
+ var/departments_bitflags = NONE
+
+ /// If specified, this department will be used for the preferences menu.
+ var/datum/job_department/department_for_prefs = null
+
+ /// Lazy list with the departments this job belongs to.
+ /// Required to be set for playable jobs.
+ /// The first department will be used in the preferences menu,
+ /// unless department_for_prefs is set.
+ /// TODO: Currently not used so will always be empty! Change this to department datums
+ var/list/departments_list = null
+
var/flag = NONE //Deprecated
+
/// Automatic deadmin for a job. Usually head/security positions
var/auto_deadmin_role_flags = NONE
// Players will be allowed to spawn in as jobs that are set to "Station"
@@ -45,7 +67,7 @@
var/exp_requirements = 0
/// Which type of XP is required see `EXP_TYPE_` in __DEFINES/preferences.dm
var/exp_type = ""
- /// Department XP required
+ /// Department XP required YOGS THIS IS NOT FUCKING SET FOR EVERY JOB I HATE WHOEVER DID THIS
var/exp_type_department = ""
/// How much antag rep this job gets increase antag chances next round unless its overriden in antag_rep.txt
var/antag_rep = 10
@@ -137,7 +159,7 @@
if(CONFIG_GET(keyed_list/job_species_whitelist)[type] && !splittext(CONFIG_GET(keyed_list/job_species_whitelist)[type], ",").Find(H.dna.species.id))
if(H.dna.species.id != "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, H.dna.species.payday_modifier)
@@ -168,6 +190,13 @@
if(CONFIG_GET(flag/everyone_has_maint_access)) //Config has global maint access set
. |= list(ACCESS_MAINT_TUNNELS)
+/mob/living/proc/dress_up_as_job(datum/job/equipping, visual_only = FALSE)
+ return
+
+/mob/living/carbon/human/dress_up_as_job(datum/job/equipping, visual_only = FALSE)
+ dna.species.before_equip_job(equipping, src, visual_only)
+ equipOutfit(equipping.outfit, visual_only)
+
/datum/job/proc/announce_head(var/mob/living/carbon/human/H, var/channels) //tells the given channel that the given mob is the new department head. See communications.dm for valid channels.
if(H && GLOB.announcement_systems.len)
//timer because these should come after the captain announcement
@@ -287,7 +316,7 @@
var/obj/item/modular_computer/PDA = new pda_type()
if(istype(PDA))
- if (H.id_in_pda)
+ if (H.client?.prefs.read_preference(/datum/preference/toggle/id_in_pda))
PDA.InsertID(C)
H.equip_to_slot_if_possible(PDA, SLOT_WEAR_ID)
else // just in case you hate change
diff --git a/code/modules/jobs/job_types/ai.dm b/code/modules/jobs/job_types/ai.dm
index 1aff3b9daa8b..e80a36327745 100644
--- a/code/modules/jobs/job_types/ai.dm
+++ b/code/modules/jobs/job_types/ai.dm
@@ -18,6 +18,10 @@
display_order = JOB_DISPLAY_ORDER_AI
var/do_special_check = TRUE
+ departments_list = list(
+ /datum/job_department/silicon,
+ )
+
alt_titles = list("Station Central Processor", "Central Silicon Intelligence", "Cyborg Overlord")
//this should never be seen because of the way olfaction works but just in case
@@ -41,7 +45,7 @@
GLOB.ai_os.set_cpu(AI, total_available_cpu)
GLOB.ai_os.add_ram(AI, total_available_ram)
- AI.apply_pref_name("ai", M.client) //If this runtimes oh well jobcode is fucked.
+ AI.apply_pref_name(/datum/preference/name/ai, M.client) //If this runtimes oh well jobcode is fucked.
AI.set_core_display_icon(null, M.client)
//we may have been created after our borg
diff --git a/code/modules/jobs/job_types/artist.dm b/code/modules/jobs/job_types/artist.dm
index 1c01b4f49868..9cdf8d3354f7 100644
--- a/code/modules/jobs/job_types/artist.dm
+++ b/code/modules/jobs/job_types/artist.dm
@@ -21,6 +21,10 @@
display_order = JOB_DISPLAY_ORDER_ARTIST
minimal_character_age = 18 //Young folks can be crazy crazy artists, something talented that can be self-taught feasibly
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
smells_like = "pain-t"
/datum/outfit/job/artist
diff --git a/code/modules/jobs/job_types/assistant.dm b/code/modules/jobs/job_types/assistant.dm
index c5b2d294cd0c..e14e86d5ba39 100644
--- a/code/modules/jobs/job_types/assistant.dm
+++ b/code/modules/jobs/job_types/assistant.dm
@@ -21,6 +21,8 @@ Assistant
display_order = JOB_DISPLAY_ORDER_ASSISTANT
minimal_character_age = 18 //Would make it even younger if I could because this role turns men into little brat boys and likewise for the other genders
+ department_for_prefs = /datum/job_department/assistant
+
mail_goodies = list(
/obj/item/reagent_containers/food/snacks/donkpocket = 10,
/obj/item/clothing/mask/gas = 10,
@@ -49,3 +51,19 @@ Assistant
uniform = /obj/item/clothing/under/color/random
uniform_skirt = /obj/item/clothing/under/skirt/color/random
return ..()
+
+
+/datum/outfit/job/assistant/consistent
+ name = "Assistant - Consistent"
+
+/datum/outfit/job/assistant/consistent/pre_equip(mob/living/carbon/human/target)
+ ..()
+ uniform = /obj/item/clothing/under/color/grey
+
+/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 a4d8d7833359..694e2a7a7882 100644
--- a/code/modules/jobs/job_types/atmospheric_technician.dm
+++ b/code/modules/jobs/job_types/atmospheric_technician.dm
@@ -23,6 +23,10 @@
display_order = JOB_DISPLAY_ORDER_ATMOSPHERIC_TECHNICIAN
minimal_character_age = 24 //Intense understanding of thermodynamics, gas law, gas interaction, construction and safe containment of gases, creation of new ones, math beyond your wildest imagination
+ departments_list = list(
+ /datum/job_department/engineering,
+ )
+
mail_goodies = list(
///obj/item/rpd_upgrade/unwrench = 30,
/obj/item/grenade/gas_crystal/crystal_foam = 10,
diff --git a/code/modules/jobs/job_types/bartender.dm b/code/modules/jobs/job_types/bartender.dm
index ba8fd46b585f..d94592cbd9eb 100644
--- a/code/modules/jobs/job_types/bartender.dm
+++ b/code/modules/jobs/job_types/bartender.dm
@@ -23,6 +23,10 @@
display_order = JOB_DISPLAY_ORDER_BARTENDER
minimal_character_age = 21 //I shouldn't have to explain this one
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
mail_goodies = list(
/obj/item/storage/box/rubbershot = 30,
/obj/item/reagent_containers/glass/bottle/clownstears = 10,
diff --git a/code/modules/jobs/job_types/botanist.dm b/code/modules/jobs/job_types/botanist.dm
index 639d62b037e8..80b97070a164 100644
--- a/code/modules/jobs/job_types/botanist.dm
+++ b/code/modules/jobs/job_types/botanist.dm
@@ -22,6 +22,10 @@
display_order = JOB_DISPLAY_ORDER_BOTANIST
minimal_character_age = 22 //Biological understanding of plants and how to manipulate their DNAs and produces relatively "safely". Not just something that comes to you without education
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
mail_goodies = list(
/obj/item/reagent_containers/glass/bottle/mutagen = 20,
/obj/item/reagent_containers/glass/bottle/saltpetre = 20,
diff --git a/code/modules/jobs/job_types/captain.dm b/code/modules/jobs/job_types/captain.dm
index 570240202dae..4f3f1ffe62ef 100644
--- a/code/modules/jobs/job_types/captain.dm
+++ b/code/modules/jobs/job_types/captain.dm
@@ -28,6 +28,11 @@
paycheck = PAYCHECK_COMMAND
paycheck_department = ACCOUNT_SEC
+ department_for_prefs = /datum/job_department/captain
+ departments_list = list(
+ /datum/job_department/command,
+ )
+
mind_traits = list(TRAIT_DISK_VERIFIER)
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/cargo_technician.dm b/code/modules/jobs/job_types/cargo_technician.dm
index 8eeee2317477..b7af99cd58fb 100644
--- a/code/modules/jobs/job_types/cargo_technician.dm
+++ b/code/modules/jobs/job_types/cargo_technician.dm
@@ -25,6 +25,10 @@
display_order = JOB_DISPLAY_ORDER_CARGO_TECHNICIAN
minimal_character_age = 18 //We love manual labor and exploiting the young for our corporate purposes
+ departments_list = list(
+ /datum/job_department/cargo,
+ )
+
mail_goodies = list(
/obj/item/pizzabox = 10,
/obj/item/stack/sheet/mineral/gold = 5,
diff --git a/code/modules/jobs/job_types/chaplain.dm b/code/modules/jobs/job_types/chaplain.dm
index d6de815d0f0e..56f1ee102aa4 100644
--- a/code/modules/jobs/job_types/chaplain.dm
+++ b/code/modules/jobs/job_types/chaplain.dm
@@ -24,6 +24,10 @@
display_order = JOB_DISPLAY_ORDER_CHAPLAIN
minimal_character_age = 18 //My guy you are literally just a priest
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
mail_goodies = list(
/obj/item/reagent_containers/food/drinks/bottle/holywater = 30,
/obj/item/toy/plush/awakenedplushie = 10,
@@ -62,17 +66,11 @@
if(H.mind)
H.mind.holy_role = HOLY_ROLE_HIGHPRIEST
- var/new_religion = DEFAULT_RELIGION
- if(M.client && M.client.prefs.custom_names["religion"])
- new_religion = M.client.prefs.custom_names["religion"]
-
- var/new_deity = DEFAULT_DEITY
- if(M.client && M.client.prefs.custom_names["deity"])
- new_deity = M.client.prefs.custom_names["deity"]
+ var/new_religion = M.client?.prefs?.read_preference(/datum/preference/name/religion) || DEFAULT_RELIGION
+ var/new_deity = M.client?.prefs?.read_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 bed0b3645cec..bb28706314c0 100644
--- a/code/modules/jobs/job_types/chemist.dm
+++ b/code/modules/jobs/job_types/chemist.dm
@@ -27,6 +27,10 @@
display_order = JOB_DISPLAY_ORDER_CHEMIST
minimal_character_age = 24 //A lot of experimental drugs plus understanding the facilitation and purpose of several subtances; what treats what and how to safely manufacture it
+ departments_list = list(
+ /datum/job_department/medical,
+ )
+
changed_maps = list("OmegaStation", "EclipseStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/chief_engineer.dm b/code/modules/jobs/job_types/chief_engineer.dm
index 249d2d36303b..3492249e7046 100644
--- a/code/modules/jobs/job_types/chief_engineer.dm
+++ b/code/modules/jobs/job_types/chief_engineer.dm
@@ -33,6 +33,11 @@
display_order = JOB_DISPLAY_ORDER_CHIEF_ENGINEER
minimal_character_age = 30 //Combine all the jobs together; that's a lot of physics, mechanical, electrical, and power-based knowledge
+ departments_list = list(
+ /datum/job_department/engineering,
+ /datum/job_department/command,
+ )
+
mail_goodies = list(
/obj/item/reagent_containers/food/snacks/cracker = 25, //you know. for poly
/obj/item/stack/sheet/mineral/diamond = 15,
diff --git a/code/modules/jobs/job_types/chief_medical_officer.dm b/code/modules/jobs/job_types/chief_medical_officer.dm
index 2ac71f4d6ae0..45030c7bc6bb 100644
--- a/code/modules/jobs/job_types/chief_medical_officer.dm
+++ b/code/modules/jobs/job_types/chief_medical_officer.dm
@@ -32,6 +32,11 @@
display_order = JOB_DISPLAY_ORDER_CHIEF_MEDICAL_OFFICER
minimal_character_age = 30 //Do you knoW HOW MANY JOBS YOU HAVE TO KNOW TO DO?? This should really be like 35 or something
+ departments_list = list(
+ /datum/job_department/medical,
+ /datum/job_department/command,
+ )
+
changed_maps = list("OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/clown.dm b/code/modules/jobs/job_types/clown.dm
index d5f38adf7a2f..7d4928fddeec 100644
--- a/code/modules/jobs/job_types/clown.dm
+++ b/code/modules/jobs/job_types/clown.dm
@@ -22,6 +22,10 @@
display_order = JOB_DISPLAY_ORDER_CLOWN
minimal_character_age = 18 //Honk
+
+ departments_list = list(
+ /datum/job_department/service,
+ )
mail_goodies = list(
/obj/item/reagent_containers/food/snacks/grown/banana = 100,
@@ -36,7 +40,7 @@
/datum/job/clown/after_spawn(mob/living/carbon/human/H, mob/M)
. = ..()
- H.apply_pref_name("clown", M.client)
+ H.apply_pref_name(/datum/preference/name/clown, M.client)
/datum/outfit/job/clown
name = "Clown"
diff --git a/code/modules/jobs/job_types/cook.dm b/code/modules/jobs/job_types/cook.dm
index 2027e47be70d..25b34fe82512 100644
--- a/code/modules/jobs/job_types/cook.dm
+++ b/code/modules/jobs/job_types/cook.dm
@@ -24,6 +24,10 @@
display_order = JOB_DISPLAY_ORDER_COOK
minimal_character_age = 18 //My guy they just a cook
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
changed_maps = list("OmegaStation", "EclipseStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/curator.dm b/code/modules/jobs/job_types/curator.dm
index 760df3f8a236..d4140d1e805c 100644
--- a/code/modules/jobs/job_types/curator.dm
+++ b/code/modules/jobs/job_types/curator.dm
@@ -24,6 +24,10 @@
display_order = JOB_DISPLAY_ORDER_CURATOR
minimal_character_age = 18 //Don't need to be some aged-ass fellow to know how to care for things, possessions could easily have come from parents and the like. Bloodsucker knowledge is another thing, though that's likely mostly consulted by the book
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
smells_like = "musty paper"
/datum/outfit/job/curator
diff --git a/code/modules/jobs/job_types/cyborg.dm b/code/modules/jobs/job_types/cyborg.dm
index 9d4689dd12e7..37b89d9a698c 100644
--- a/code/modules/jobs/job_types/cyborg.dm
+++ b/code/modules/jobs/job_types/cyborg.dm
@@ -17,6 +17,10 @@
display_order = JOB_DISPLAY_ORDER_CYBORG
+ departments_list = list(
+ /datum/job_department/silicon,
+ )
+
changed_maps = list("EclipseStation", "OmegaStation")
smells_like = "inorganic indifference"
@@ -52,8 +56,8 @@
if(!is_donator(C))
return
- if(C.prefs.donor_hat && C.prefs.borg_hat)
- var/type = C.prefs.donor_hat
+ if(C.prefs.read_preference(/datum/preference/toggle/borg_hat))
+ var/type = GLOB.donator_gear.item_names[C.prefs.read_preference(/datum/preference/choiced/donor_hat)]
if(type)
var/obj/item/hat = new type()
if(istype(hat) && hat.slot_flags & ITEM_SLOT_HEAD && H.hat_offset != INFINITY && !is_type_in_typecache(hat, H.blacklisted_hats))
diff --git a/code/modules/jobs/job_types/detective.dm b/code/modules/jobs/job_types/detective.dm
index 84b66d787dca..623c7c2ae422 100644
--- a/code/modules/jobs/job_types/detective.dm
+++ b/code/modules/jobs/job_types/detective.dm
@@ -29,6 +29,10 @@
display_order = JOB_DISPLAY_ORDER_DETECTIVE
minimal_character_age = 22 //Understanding of forensics, crime analysis, and theory. Less of a grunt officer and more of an intellectual, theoretically, despite how this is never reflected in-game
+ departments_list = list(
+ /datum/job_department/security,
+ )
+
mail_goodies = list(
///obj/item/storage/fancy/cigarettes = 25,
/obj/item/ammo_box/c38 = 25,
diff --git a/code/modules/jobs/job_types/geneticist.dm b/code/modules/jobs/job_types/geneticist.dm
index 426e228cbc4b..5b32ed194f02 100644
--- a/code/modules/jobs/job_types/geneticist.dm
+++ b/code/modules/jobs/job_types/geneticist.dm
@@ -24,6 +24,10 @@
display_order = JOB_DISPLAY_ORDER_GENETICIST
minimal_character_age = 24 //Genetics would likely require more education than your average position due to the sheer number of alien physiologies and experimental nature of the field
+ departments_list = list(
+ /datum/job_department/medical,
+ )
+
mail_goodies = list(
/obj/item/storage/box/monkeycubes = 10
)
diff --git a/code/modules/jobs/job_types/head_of_personnel.dm b/code/modules/jobs/job_types/head_of_personnel.dm
index bbc1ec396613..394bb5702529 100644
--- a/code/modules/jobs/job_types/head_of_personnel.dm
+++ b/code/modules/jobs/job_types/head_of_personnel.dm
@@ -36,6 +36,11 @@
display_order = JOB_DISPLAY_ORDER_HEAD_OF_PERSONNEL
minimal_character_age = 26 //Baseline age requirement and competency, as well as ability to assume leadership in shite situations
+ departments_list = list(
+ /datum/job_department/service,
+ /datum/job_department/command,
+ )
+
changed_maps = list("OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/head_of_security.dm b/code/modules/jobs/job_types/head_of_security.dm
index c2915f4b364e..43c4d36bf2af 100644
--- a/code/modules/jobs/job_types/head_of_security.dm
+++ b/code/modules/jobs/job_types/head_of_security.dm
@@ -34,6 +34,11 @@
display_order = JOB_DISPLAY_ORDER_HEAD_OF_SECURITY
minimal_character_age = 28 //You need some experience on your belt and a little gruffiness; you're still a foot soldier, not quite a tactician commander back at base
+ departments_list = list(
+ /datum/job_department/security,
+ /datum/job_department/command,
+ )
+
changed_maps = list("YogsPubby")
smells_like = "deadly authority"
diff --git a/code/modules/jobs/job_types/janitor.dm b/code/modules/jobs/job_types/janitor.dm
index 8b6941a746d0..460ab8082f36 100644
--- a/code/modules/jobs/job_types/janitor.dm
+++ b/code/modules/jobs/job_types/janitor.dm
@@ -23,6 +23,10 @@
display_order = JOB_DISPLAY_ORDER_JANITOR
minimal_character_age = 20 //Theoretically janitors do actually need training and certifications in handling of certain hazardous materials as well as cleaning substances, but nothing absurd, I'd assume
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
changed_maps = list("OmegaStation", "EclipseStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/lawyer.dm b/code/modules/jobs/job_types/lawyer.dm
index df657a12ba3d..66588759d2f5 100644
--- a/code/modules/jobs/job_types/lawyer.dm
+++ b/code/modules/jobs/job_types/lawyer.dm
@@ -25,6 +25,10 @@
display_order = JOB_DISPLAY_ORDER_LAWYER
minimal_character_age = 24 //Law is already absurd, never mind the wacky-ass shit that is space law
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
changed_maps = list("OmegaStation")
smells_like = "legal lies"
diff --git a/code/modules/jobs/job_types/medical_doctor.dm b/code/modules/jobs/job_types/medical_doctor.dm
index 38a22a19b31d..070ebb3b5ef6 100644
--- a/code/modules/jobs/job_types/medical_doctor.dm
+++ b/code/modules/jobs/job_types/medical_doctor.dm
@@ -25,6 +25,10 @@
display_order = JOB_DISPLAY_ORDER_MEDICAL_DOCTOR
minimal_character_age = 26 //Barely acceptable considering the theoretically absurd knowledge they have, but fine
+ departments_list = list(
+ /datum/job_department/medical,
+ )
+
changed_maps = list("EclipseStation", "OmegaStation")
mail_goodies = list(
@@ -54,10 +58,8 @@
/datum/outfit/job/doctor
name = "Medical Doctor"
jobtype = /datum/job/doctor
-
- pda_type= /obj/item/modular_computer/tablet/pda/preset/medical
-
ears = /obj/item/radio/headset/headset_med
+ pda_type = /obj/item/modular_computer/tablet/pda/preset/medical
uniform = /obj/item/clothing/under/rank/medical
uniform_skirt = /obj/item/clothing/under/rank/medical/skirt
shoes = /obj/item/clothing/shoes/sneakers/white
@@ -68,5 +70,15 @@
backpack = /obj/item/storage/backpack/medic
satchel = /obj/item/storage/backpack/satchel/med
duffelbag = /obj/item/storage/backpack/duffelbag/med
-
chameleon_extras = /obj/item/gun/syringe
+/datum/outfit/job/doctor/dead
+ name = "Medical Doctor"
+ jobtype = /datum/job/doctor
+ ears = /obj/item/radio/headset/headset_med
+ uniform = /obj/item/clothing/under/rank/medical
+ shoes = /obj/item/clothing/shoes/sneakers/white
+ suit = /obj/item/clothing/suit/toggle/labcoat/md
+ l_hand = /obj/item/storage/firstaid/medical
+ suit_store = /obj/item/flashlight/pen
+ gloves = /obj/item/clothing/gloves/color/latex/nitrile
+ pda_type = /obj/item/pda/medical
diff --git a/code/modules/jobs/job_types/mime.dm b/code/modules/jobs/job_types/mime.dm
index 7c624b5a7b16..0871df5b1ce4 100644
--- a/code/modules/jobs/job_types/mime.dm
+++ b/code/modules/jobs/job_types/mime.dm
@@ -23,6 +23,10 @@
display_order = JOB_DISPLAY_ORDER_MIME
minimal_character_age = 18 //Mime?? Might increase this a LOT depending on how mime lore turns out
+ departments_list = list(
+ /datum/job_department/service,
+ )
+
mail_goodies = list(
/obj/item/reagent_containers/food/snacks/baguette = 15,
/obj/item/reagent_containers/food/snacks/store/cheesewheel = 10,
@@ -33,7 +37,7 @@
smells_like = "complete nothingness"
/datum/job/mime/after_spawn(mob/living/carbon/human/H, mob/M)
- H.apply_pref_name("mime", M.client)
+ H.apply_pref_name(/datum/preference/name/mime, M.client)
/datum/outfit/job/mime
name = "Mime"
diff --git a/code/modules/jobs/job_types/quartermaster.dm b/code/modules/jobs/job_types/quartermaster.dm
index 9142fbd4d807..b96ac4868e2c 100644
--- a/code/modules/jobs/job_types/quartermaster.dm
+++ b/code/modules/jobs/job_types/quartermaster.dm
@@ -20,6 +20,10 @@
display_order = JOB_DISPLAY_ORDER_QUARTERMASTER
minimal_character_age = 20 //Probably just needs some baseline experience with bureaucracy, enough trust to land the position
+ departments_list = list(
+ /datum/job_department/cargo,
+ )
+
changed_maps = list("OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/research_director.dm b/code/modules/jobs/job_types/research_director.dm
index 01ddd7feb833..00944c7315c2 100644
--- a/code/modules/jobs/job_types/research_director.dm
+++ b/code/modules/jobs/job_types/research_director.dm
@@ -36,6 +36,11 @@
display_order = JOB_DISPLAY_ORDER_RESEARCH_DIRECTOR
minimal_character_age = 26 //Barely knows more than actual scientists, just responsibility and AI things
+ departments_list = list(
+ /datum/job_department/science,
+ /datum/job_department/command,
+ )
+
changed_maps = list("OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/roboticist.dm b/code/modules/jobs/job_types/roboticist.dm
index a70486c50c36..cc30dd364d43 100644
--- a/code/modules/jobs/job_types/roboticist.dm
+++ b/code/modules/jobs/job_types/roboticist.dm
@@ -24,6 +24,10 @@
display_order = JOB_DISPLAY_ORDER_ROBOTICIST
minimal_character_age = 22 //Engineering, AI theory, robotic knowledge and the like
+ departments_list = list(
+ /datum/job_department/science,
+ )
+
changed_maps = list("OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/scientist.dm b/code/modules/jobs/job_types/scientist.dm
index a8c13a8a5a05..8aa8852b30a8 100644
--- a/code/modules/jobs/job_types/scientist.dm
+++ b/code/modules/jobs/job_types/scientist.dm
@@ -23,6 +23,10 @@
display_order = JOB_DISPLAY_ORDER_SCIENTIST
minimal_character_age = 24 //Consider the level of knowledge that spans xenobio, nanites, and toxins
+ departments_list = list(
+ /datum/job_department/science,
+ )
+
changed_maps = list("EclipseStation", "OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/security_officer.dm b/code/modules/jobs/job_types/security_officer.dm
index 39500e703b60..9a4d1a9db8e4 100644
--- a/code/modules/jobs/job_types/security_officer.dm
+++ b/code/modules/jobs/job_types/security_officer.dm
@@ -29,6 +29,10 @@
display_order = JOB_DISPLAY_ORDER_SECURITY_OFFICER
minimal_character_age = 18 //Just a few months of boot camp, not a whole year
+ departments_list = list(
+ /datum/job_department/security,
+ )
+
changed_maps = list("EclipseStation", "YogsPubby", "OmegaStation")
mail_goodies = list(
@@ -67,7 +71,7 @@ GLOBAL_LIST_INIT(available_depts_sec, list(SEC_DEPT_ENGINEERING, SEC_DEPT_MEDICA
// Assign department security
var/department
if(M && M.client && M.client.prefs)
- department = M.client.prefs.prefered_security_department
+ department = M.client?.prefs?.read_preference(/datum/preference/choiced/security_department)
if(!LAZYLEN(GLOB.available_depts_sec) || department == "None")
return
else if(department in GLOB.available_depts_sec)
diff --git a/code/modules/jobs/job_types/shaft_miner.dm b/code/modules/jobs/job_types/shaft_miner.dm
index 81d69aed467d..e76a93cbb256 100644
--- a/code/modules/jobs/job_types/shaft_miner.dm
+++ b/code/modules/jobs/job_types/shaft_miner.dm
@@ -23,6 +23,10 @@
display_order = JOB_DISPLAY_ORDER_SHAFT_MINER
minimal_character_age = 18 //Young and fresh bodies for a high mortality job, what more could you ask for
+ departments_list = list(
+ /datum/job_department/cargo,
+ )
+
changed_maps = list("EclipseStation", "OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/station_engineer.dm b/code/modules/jobs/job_types/station_engineer.dm
index ae72933e4d78..59efae7b9eca 100644
--- a/code/modules/jobs/job_types/station_engineer.dm
+++ b/code/modules/jobs/job_types/station_engineer.dm
@@ -26,6 +26,10 @@
display_order = JOB_DISPLAY_ORDER_STATION_ENGINEER
minimal_character_age = 22 //You need to know a lot of complicated stuff about engines, could theoretically just have a traditional bachelor's
+ departments_list = list(
+ /datum/job_department/engineering,
+ )
+
changed_maps = list("EclipseStation", "OmegaStation")
mail_goodies = list(
@@ -56,7 +60,7 @@ GLOBAL_LIST_INIT(available_depts_eng, list(ENG_DEPT_MEDICAL, ENG_DEPT_SCIENCE, E
// Assign department engineering
var/department
if(M && M.client && M.client.prefs)
- department = M.client.prefs.prefered_engineering_department
+ department = M.client.prefs.read_preference(/datum/preference/choiced/engineering_department)
if(!LAZYLEN(GLOB.available_depts_eng) || department == "None")
return
else if(department in GLOB.available_depts_eng)
diff --git a/code/modules/jobs/job_types/virologist.dm b/code/modules/jobs/job_types/virologist.dm
index fa6aca62c1ef..5bbeee15d52a 100644
--- a/code/modules/jobs/job_types/virologist.dm
+++ b/code/modules/jobs/job_types/virologist.dm
@@ -28,6 +28,10 @@
display_order = JOB_DISPLAY_ORDER_VIROLOGIST
minimal_character_age = 24 //Requires understanding of microbes, biology, infection, and all the like, as well as being able to understand how to interface the machines. Epidemiology is no joke of a field
+ departments_list = list(
+ /datum/job_department/medical,
+ )
+
changed_maps = list("OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/job_types/warden.dm b/code/modules/jobs/job_types/warden.dm
index 8b8552620f4b..927b09d77a3c 100644
--- a/code/modules/jobs/job_types/warden.dm
+++ b/code/modules/jobs/job_types/warden.dm
@@ -31,6 +31,10 @@
display_order = JOB_DISPLAY_ORDER_WARDEN
minimal_character_age = 20 //You're a sergeant, probably has some experience in the field
+ departments_list = list(
+ /datum/job_department/security,
+ )
+
changed_maps = list("YogsPubby", "OmegaStation")
mail_goodies = list(
diff --git a/code/modules/jobs/jobs.dm b/code/modules/jobs/jobs.dm
index 77099b366b55..3841c9f28b5d 100644
--- a/code/modules/jobs/jobs.dm
+++ b/code/modules/jobs/jobs.dm
@@ -58,7 +58,7 @@ GLOBAL_LIST_INIT(original_security_positions, list(
GLOBAL_LIST_INIT(original_nonhuman_positions, list(
"AI",
"Cyborg",
- ROLE_PAI))
+ "pAI"))
GLOBAL_LIST_INIT(alt_command_positions, list(
"Station Commander", "Facility Director", "Chief Executive Officer",
diff --git a/code/modules/keybindings/bindings_client.dm b/code/modules/keybindings/bindings_client.dm
index 1168219acbd6..b5c8c9424944 100644
--- a/code/modules/keybindings/bindings_client.dm
+++ b/code/modules/keybindings/bindings_client.dm
@@ -6,7 +6,7 @@
set hidden = TRUE
//Focus Chat failsafe. Overrides movement checks to prevent WASD.
- if(!prefs.hotkeys && length(_key) == 1 && _key != "Alt" && _key != "Ctrl" && _key != "Shift")
+ if(!hotkeys && length(_key) == 1 && _key != "Alt" && _key != "Ctrl" && _key != "Shift")
winset(src, null, "input.focus=true ; input.text=[url_encode(_key)]")
return
@@ -36,7 +36,7 @@
full_key = _key
var/keycount = 0
- for(var/kb_name in prefs.key_bindings[full_key])
+ for(var/kb_name in prefs.key_bindings_by_key[full_key])
keycount++
var/datum/keybinding/kb = GLOB.keybindings_by_name[kb_name]
if(kb.can_use(src) && kb.down(src) && keycount >= MAX_COMMANDS_PER_KEY)
@@ -66,7 +66,7 @@
// 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
- 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)
stack_trace("Invalid keybind found in keyUp: _key=[_key]; kb_name=[kb_name]")
diff --git a/code/modules/keybindings/setup.dm b/code/modules/keybindings/setup.dm
index ab03c172efd9..5e98192e9262 100644
--- a/code/modules/keybindings/setup.dm
+++ b/code/modules/keybindings/setup.dm
@@ -32,7 +32,7 @@
var/command = macro_set[key]
winset(src, "default-[REF(key)]", "parent=default;name=[key];command=[command]")
- if(prefs?.hotkeys)
+ if(hotkeys)
winset(src, null, "input.focus=true input.background-color=[COLOR_INPUT_ENABLED]")
else
winset(src, null, "input.focus=true input.background-color=[COLOR_INPUT_DISABLED]")
diff --git a/code/modules/language/language_holder.dm b/code/modules/language/language_holder.dm
index 4e3945179672..663556c866d1 100644
--- a/code/modules/language/language_holder.dm
+++ b/code/modules/language/language_holder.dm
@@ -59,7 +59,10 @@ Key procs
var/datum/mind/M = owner
if(M.current)
update_atom_languages(M.current)
- 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/mob/dead/new_player/latejoin_menu.dm b/code/modules/mob/dead/new_player/latejoin_menu.dm
index e58eda66ae6d..8d942601ec20 100644
--- a/code/modules/mob/dead/new_player/latejoin_menu.dm
+++ b/code/modules/mob/dead/new_player/latejoin_menu.dm
@@ -17,6 +17,20 @@ GLOBAL_DATUM_INIT(latejoin_menu, /datum/latejoin_menu, new)
user.AttemptLateSpawn(input_contents)
+/datum/latejoin_menu/verb/open_fallback_ui()
+ set category = "Preferences"
+ set name = "Open fallback latejoin menu"
+ set desc = "Open fallback latejoin menu"
+
+ if (!istype(usr, /mob/dead/new_player))
+ to_chat(usr, span_notice("You cannot do this at this time!"))
+ return
+
+ if (!GLOB.latejoin_menu.check_latejoin_eligibility(usr, use_chat = TRUE))
+ return
+
+ GLOB.latejoin_menu.fallback_ui(usr)
+
/datum/latejoin_menu/ui_close(mob/dead/new_player/user)
. = ..()
if(istype(user))
@@ -36,7 +50,7 @@ GLOBAL_DATUM_INIT(latejoin_menu, /datum/latejoin_menu, new)
/datum/latejoin_menu/proc/scream_at_player(mob/dead/new_player/player)
if(!player.jobs_menu_mounted)
- to_chat(player, span_notice("If the late join menu isn't showing, hold CTRL while clicking the join button!"))
+ to_chat(player, span_notice("If the late join menu isn't showing, you can open the fallback menu using the verb in the Preferences tab!"))
/datum/latejoin_menu/ui_data(mob/user)
var/mob/dead/new_player/owner = user
@@ -58,20 +72,15 @@ GLOBAL_DATUM_INIT(latejoin_menu, /datum/latejoin_menu, new)
if(prioritized_job.current_positions >= prioritized_job.total_positions)
SSjob.prioritized_jobs -= prioritized_job
- for(var/list/category in list(GLOB.command_positions) + list(GLOB.engineering_positions) + list(GLOB.supply_positions) + list(GLOB.nonhuman_positions - "pAI") + list(GLOB.civilian_positions) + list(GLOB.science_positions) + list(GLOB.security_positions) + list(GLOB.medical_positions) )
- var/cat_name = SSjob.name_occupations_all[category[1]].exp_type_department
-
+ for(var/datum/job_department/department as anything in SSjob.joinable_departments)
var/list/department_jobs = list()
var/list/department_data = list(
"jobs" = department_jobs,
"open_slots" = 0,
)
- departments[cat_name] = department_data
- for(var/job in category)
- var/datum/job/job_datum = SSjob.name_occupations[job]
- if (!job_datum)
- continue
+ departments[department.department_name] = department_data
+ for(var/datum/job/job_datum as anything in department.department_jobs)
var/job_availability = owner.IsJobUnavailable(job_datum.title, latejoin = TRUE)
var/list/job_data = list(
@@ -97,26 +106,17 @@ GLOBAL_DATUM_INIT(latejoin_menu, /datum/latejoin_menu, new)
/datum/latejoin_menu/ui_static_data(mob/user)
var/list/departments = list()
- for(var/list/category in list(GLOB.command_positions) + list(GLOB.engineering_positions) + list(GLOB.supply_positions) + list(GLOB.nonhuman_positions - "pAI") + list(GLOB.civilian_positions) + list(GLOB.science_positions) + list(GLOB.security_positions) + list(GLOB.medical_positions) )
- var/cat_color = SSjob.name_occupations_all[category[1]].selection_color
- var/cat_name = SSjob.name_occupations_all[category[1]].exp_type_department
-
+ for(var/datum/job_department/department as anything in SSjob.joinable_departments)
var/list/department_jobs = list()
var/list/department_data = list(
"jobs" = department_jobs,
- "color" = cat_color,
+ "color" = department.ui_color,
)
- departments[cat_name] = department_data
-
- for(var/job in category)
- var/datum/job/job_datum = SSjob.name_occupations[job]
- if (!job_datum)
- continue
-
- var/is_command = (job in GLOB.command_positions)
+ departments[department.department_name] = department_data
+ for(var/datum/job/job_datum as anything in department.department_jobs)
var/list/job_data = list(
- "command" = is_command,
+ "command" = !!(job_datum.departments_bitflags & DEPARTMENT_BITFLAG_COMMAND),
"description" = job_datum.description,
"icon" = job_datum.orbit_icon,
)
@@ -149,33 +149,50 @@ GLOBAL_DATUM_INIT(latejoin_menu, /datum/latejoin_menu, new)
params["job"] = job
- if(!SSticker?.IsRoundInProgress())
- tgui_alert(owner, "The round is either not ready, or has already finished...", "Oh No!")
+ if (!check_latejoin_eligibility(owner))
return TRUE
- if(!GLOB.enter_allowed || SSticker.late_join_disabled)
- tgui_alert(owner, "There is an administrative lock on entering the game for non-observers!", "Oh No!")
- return TRUE
-
- //Determines Relevent Population Cap
- var/relevant_cap
- var/hard_popcap = CONFIG_GET(number/hard_popcap)
- var/extreme_popcap = CONFIG_GET(number/extreme_popcap)
- if(hard_popcap && extreme_popcap)
- relevant_cap = min(hard_popcap, extreme_popcap)
- else
- relevant_cap = max(hard_popcap, extreme_popcap)
-
- if(SSticker.queued_players.len)
- if((living_player_count() >= relevant_cap) || (owner != SSticker.queued_players[1]))
- tgui_alert(owner, "The server is full!", "Oh No!")
- return TRUE
-
+ remove_verb(owner, /datum/latejoin_menu/verb/open_fallback_ui)
// SAFETY: AttemptLateSpawn has it's own sanity checks. This is perfectly safe.
owner.AttemptLateSpawn(params["job"])
return TRUE
+
+/datum/latejoin_menu/proc/check_latejoin_eligibility(mob/dead/new_player/owner, var/use_chat = FALSE)
+ if(!SSticker?.IsRoundInProgress())
+ if (use_chat)
+ to_chat(owner, span_notice("The round is either not ready, or has already finished..."))
+ else
+ tgui_alert(owner, "The round is either not ready, or has already finished...", "Oh No!")
+ return FALSE
+
+ if(!GLOB.enter_allowed || SSticker.late_join_disabled)
+ if (use_chat)
+ to_chat(owner, span_notice("There is an administrative lock on entering the game for non-observers!"))
+ else
+ tgui_alert(owner, "There is an administrative lock on entering the game for non-observers!", "Oh No!")
+ return FALSE
+
+ //Determines Relevent Population Cap
+ var/relevant_cap
+ var/hard_popcap = CONFIG_GET(number/hard_popcap)
+ var/extreme_popcap = CONFIG_GET(number/extreme_popcap)
+ if(hard_popcap && extreme_popcap)
+ relevant_cap = min(hard_popcap, extreme_popcap)
+ else
+ relevant_cap = max(hard_popcap, extreme_popcap)
+
+ if(SSticker.queued_players.len)
+ if((living_player_count() >= relevant_cap) || (owner != SSticker.queued_players[1]))
+ if (use_chat)
+ to_chat(owner, span_notice("The server is full!"))
+ else
+ tgui_alert(owner, "The server is full!", "Oh No!")
+ return FALSE
+
+ return TRUE
+
/// Gives the user a random job that they can join as, and prompts them if they'd actually like to keep it, rerolling if not. Cancellable by the user.
/// WARNING: BLOCKS THREAD!
/datum/latejoin_menu/proc/get_random_job(mob/dead/new_player/owner)
diff --git a/code/modules/mob/dead/new_player/new_player.dm b/code/modules/mob/dead/new_player/new_player.dm
index 8d0e81b51aa2..19d90f266f4a 100644
--- a/code/modules/mob/dead/new_player/new_player.dm
+++ b/code/modules/mob/dead/new_player/new_player.dm
@@ -31,6 +31,8 @@
ComponentInitialize()
+ add_verb(usr, /datum/latejoin_menu/verb/open_fallback_ui)
+
. = ..()
GLOB.new_player_list += src
@@ -46,6 +48,7 @@
var/datum/asset/asset_datum = get_asset_datum(/datum/asset/simple/lobby)
asset_datum.send(client)
var/output = " Setup Character
"
+ output += "Game Options
"
if(SSticker.current_state <= GAME_STATE_PREGAME)
switch(ready)
@@ -117,8 +120,18 @@
relevant_cap = max(hpc, epc)
if(href_list["show_preferences"])
- client.prefs.ShowChoices(src)
- return 1
+ var/datum/preferences/preferences = client.prefs
+ preferences.current_window = PREFERENCE_TAB_CHARACTER_PREFERENCES
+ preferences.update_static_data(usr)
+ preferences.ui_interact(usr)
+ return TRUE
+
+ if(href_list["show_gameoptions"])
+ var/datum/preferences/preferences = client.prefs
+ preferences.current_window = PREFERENCE_TAB_GAME_PREFERENCES
+ preferences.update_static_data(usr)
+ preferences.ui_interact(usr)
+ return TRUE
if(href_list["ready"])
var/tready = text2num(href_list["ready"])
@@ -142,10 +155,6 @@
to_chat(usr, span_danger("The round is either not ready, or has already finished..."))
return
- if(href_list["late_join"] == "override")
- GLOB.latejoin_menu.ui_interact(src)
- return
-
if(SSticker.queued_players.len || (relevant_cap && living_player_count() >= relevant_cap && !(ckey(key) in GLOB.permissions.admin_datums)))
//yogs start -- donors bypassing the queue
if(ckey(key) in get_donators())
@@ -165,10 +174,8 @@
to_chat(usr, span_notice("You have been added to the queue to join the game. Your position in queue is [SSticker.queued_players.len]."))
return
- // TODO: Fallback menu
GLOB.latejoin_menu.ui_interact(usr)
-
if(href_list["manifest"])
ViewManifest()
@@ -289,7 +296,7 @@
observer.client = client
observer.set_ghost_appearance()
if(observer.client && observer.client.prefs)
- observer.real_name = observer.client.prefs.real_name
+ observer.real_name = observer.client.prefs.read_preference(/datum/preference/name/real_name)
observer.name = observer.real_name
observer.client.init_verbs()
observer.update_icon()
@@ -445,13 +452,11 @@
if(QDELETED(src))
return
if(frn)
- client.prefs.random_character()
- client.prefs.accent = null
- client.prefs.real_name = client.prefs.pref_species.random_name(gender,1)
- client.prefs.copy_to(H)
+ client.prefs.randomise_appearance_prefs()
- client.prefs.copy_to(H)
+ client.prefs.apply_prefs_to(H)
H.dna.update_dna_identity()
+
if(mind)
if(mind.assigned_role)
var/datum/job/J = SSjob.GetJob(mind.assigned_role)
@@ -462,8 +467,11 @@
mind.late_joiner = TRUE
mind.active = FALSE //we wish to transfer the key manually
mind.original_character_slot_index = client.prefs.default_slot
- if(!HAS_TRAIT(H,TRAIT_RANDOM_ACCENT))
- mind.accent_name = client.prefs.accent
+ if(!HAS_TRAIT(H, TRAIT_RANDOM_ACCENT))
+ var/accent_name = client.prefs.read_preference(/datum/preference/choiced/accent)
+ if (accent_name == ACCENT_NONE)
+ accent_name = null
+ mind.accent_name = accent_name
mind.transfer_to(H) //won't transfer key since the mind is not active
mind.original_character = H
@@ -512,7 +520,7 @@
/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.joblessrole != RETURNTOLOBBY)
+ if(client.prefs.read_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 = FALSE
diff --git a/code/modules/mob/dead/new_player/preferences_setup.dm b/code/modules/mob/dead/new_player/preferences_setup.dm
index 7703b6c0f551..eefa3e385051 100644
--- a/code/modules/mob/dead/new_player/preferences_setup.dm
+++ b/code/modules/mob/dead/new_player/preferences_setup.dm
@@ -1,65 +1,55 @@
- //The mob should have a gender you want before running this proc. Will run fine without H
-/datum/preferences/proc/random_character(gender_override)
- if(gender_override)
- gender = gender_override
- else
- gender = pick(MALE,FEMALE)
- if(!random_locks["underwear"])
- underwear = random_underwear(gender)
- if(!random_locks["undershirt"])
- undershirt = random_undershirt(gender)
- if(!random_locks["socks"])
- socks = random_socks()
- if(!random_locks["skin_tone"])
- skin_tone = random_skin_tone()
- if(!random_locks["hair_style"])
- hair_style = random_hair_style(gender)
- if(!random_locks["facial_hair_style"])
- facial_hair_style = random_facial_hair_style(gender)
- if(!random_locks["hair"])
- hair_color = random_short_color()
- if(!random_locks["facial"])
- facial_hair_color = hair_color
- if(!random_locks["eye_color"])
- eye_color = random_eye_color()
- if(!pref_species)
- var/rando_race = pick(GLOB.roundstart_races)
- pref_species = new rando_race()
- var/temp_features = random_features()
- for(var/i in temp_features)
- if(random_locks[i])
- i = features[i]
- features = temp_features
- age = rand(AGE_MIN,AGE_MAX)
+/// Fully randomizes everything in the character.
+/datum/preferences/proc/randomise_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
-/datum/preferences/proc/update_preview_icon()
- // Determine what job is marked as 'High' priority, and dress them up as such.
- var/datum/job/previewJob
+ 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_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 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)
- previewJob = SSjob.GetJob(job)
+ preview_job = SSjob.GetJob(job)
highest_pref = job_preferences[job]
- if(previewJob)
+ return preview_job
+
+/datum/preferences/proc/render_new_preview_appearance(mob/living/carbon/human/dummy/mannequin)
+ var/datum/job/preview_job = get_highest_priority_job()
+
+ if(preview_job)
// 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
+ if (istype(preview_job, /datum/job/ai))
+ return image('icons/mob/ai.dmi', icon_state = resolve_ai_icon(read_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
- var/mob/living/carbon/human/dummy/mannequin = generate_or_wait_for_human_dummy(DUMMY_HUMAN_SLOT_PREFERENCES)
mannequin.add_overlay(mutable_appearance('icons/turf/floors.dmi', background, layer = SPACE_LAYER))
- copy_to(mannequin)
+ apply_prefs_to(mannequin, TRUE)
- if(previewJob)
- mannequin.job = previewJob.title
- previewJob.equip(mannequin, TRUE, preference_source = parent)
+ if(preview_job)
+ mannequin.job = preview_job.title
+ mannequin.dress_up_as_job(preview_job, TRUE)
- COMPILE_OVERLAYS(mannequin)
- parent.show_character_previews(new /mutable_appearance(mannequin))
- unset_busy_human_dummy(DUMMY_HUMAN_SLOT_PREFERENCES)
+ return mannequin.appearance
diff --git a/code/modules/mob/dead/observer/login.dm b/code/modules/mob/dead/observer/login.dm
index 1b328dbc697c..e39207ffde59 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_preference(/datum/preference/choiced/ghost_accessories)
+ ghost_others = client.prefs.read_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
+ if(is_donator(client))
+ preferred_form = client.prefs.read_preference(/datum/preference/choiced/ghost_form)
+ ghost_orbit = client.prefs.read_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 5ff31f655d36..00b8128aec36 100644
--- a/code/modules/mob/dead/observer/observer.dm
+++ b/code/modules/mob/dead/observer/observer.dm
@@ -185,8 +185,8 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_OBSERVER)
*/
/mob/dead/observer/proc/update_icon(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
+ ghost_accs = client.prefs.read_preference(/datum/preference/choiced/ghost_accessories)
+ ghost_others = client.prefs.read_preference(/datum/preference/choiced/ghost_others)
if(hair_overlay)
cut_overlay(hair_overlay)
@@ -204,7 +204,7 @@ GLOBAL_VAR_INIT(observer_default_invisibility, INVISIBILITY_OBSERVER)
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
@@ -355,7 +355,7 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp
if(mind.current.key && mind.current.key[1] != "@") //makes sure we don't accidentally kick any clients
to_chat(usr, span_warning("Another consciousness is in your body...It is resisting you."))
return
- client.view_size.setDefault(getScreenSize(client.prefs.widescreenpref))//Let's reset so people can't become allseeing gods
+ client.view_size.setDefault(getScreenSize(client.prefs.read_preference(/datum/preference/toggle/widescreen)))//Let's reset so people can't become allseeing gods
SStgui.on_transfer(src, mind.current) // Transfer NanoUIs.
mind.current.key = key
mind.current.oobe_client = null //yogs
@@ -387,8 +387,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_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
@@ -578,7 +579,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_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
@@ -606,11 +607,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_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)
@@ -779,26 +780,31 @@ This is the proc mobs get to turn into a ghost. Forked from ghostize due to comp
set category = "Ghost"
set_ghost_appearance()
- if(client && client.prefs)
- deadchat_name = client.prefs.real_name
+ if(client?.prefs)
+ var/real_name = client.prefs.read_preference(/datum/preference/name/real_name)
+ deadchat_name = real_name
if(mind)
- mind.name = client.prefs.real_name
+ mind.name = real_name
+ name = real_name
/mob/dead/observer/proc/set_ghost_appearance()
if((!client) || (!client.prefs))
return
- if(client.prefs.be_random_name)
- client.prefs.real_name = random_unique_name(gender)
- if(client.prefs.be_random_body)
- client.prefs.random_character(gender)
-
- if(HAIR in client.prefs.pref_species.species_traits)
- hair_style = client.prefs.hair_style
- hair_color = brighten_color(client.prefs.hair_color)
- if(FACEHAIR in client.prefs.pref_species.species_traits)
- facial_hair_style = client.prefs.facial_hair_style
- facial_hair_color = brighten_color(client.prefs.facial_hair_color)
+ client.prefs.apply_character_randomization_prefs()
+
+ var/species_type = client.prefs.read_preference(/datum/preference/choiced/species)
+ var/datum/species/species = new species_type
+
+ if(HAIR in species.species_traits)
+ hair_style = client.prefs.read_preference(/datum/preference/choiced/hairstyle)
+ hair_color = brighten_color(client.prefs.read_preference(/datum/preference/color_legacy/hair_color))
+
+ if(FACEHAIR in species.species_traits)
+ facial_hair_style = client.prefs.read_preference(/datum/preference/choiced/facial_hairstyle)
+ facial_hair_color = brighten_color(client.prefs.read_preference(/datum/preference/color_legacy/facial_hair_color))
+
+ qdel(species)
update_icon()
diff --git a/code/modules/mob/dead/observer/say.dm b/code/modules/mob/dead/observer/say.dm
index d441bb33def1..0e3dc94844e8 100644
--- a/code/modules/mob/dead/observer/say.dm
+++ b/code/modules/mob/dead/observer/say.dm
@@ -43,7 +43,7 @@
to_follow = V.source
var/link = FOLLOW_LINK(src, to_follow)
// Create map text prior to modifying message for goonchat
- if (client?.prefs.chat_on_map && (client.prefs.see_chat_non_mob || ismob(speaker)))
+ if (client?.prefs.read_preference(/datum/preference/toggle/enable_runechat) && (client.prefs.read_preference(/datum/preference/toggle/enable_runechat_non_mobs) || ismob(speaker)))
create_chat_message(speaker, message_language, raw_message, spans)
// Recompose the message, because it's scrambled by default
message = compose_message(speaker, message_language, raw_message, radio_freq, spans, message_mods)
diff --git a/code/modules/mob/living/carbon/human/dummy.dm b/code/modules/mob/living/carbon/human/dummy.dm
index ce9b1e64d9af..8391acb58f06 100644
--- a/code/modules/mob/living/carbon/human/dummy.dm
+++ b/code/modules/mob/living/carbon/human/dummy.dm
@@ -69,10 +69,47 @@ INITIALIZE_IMMEDIATE(/mob/living/carbon/human/dummy)
cut_overlays(TRUE)
/mob/living/carbon/human/dummy/setup_human_dna()
- create_dna(src)
+ create_dna()
randomize_human(src)
dna.initialize_dna(skip_index = TRUE) //Skip stuff that requires full round init.
+/proc/create_consistent_human_dna(mob/living/carbon/human/target)
+ target.dna.initialize_dna(skip_index = TRUE)
+ target.dna.features["body_markings"] = "None"
+ target.dna.features["ears"] = "None"
+ target.dna.features["ethcolor"] = "EEEEEE" // white-ish
+ target.dna.features["frills"] = "None"
+ target.dna.features["horns"] = "None"
+ target.dna.features["mcolor"] = COLOR_VIBRANT_LIME
+ target.dna.features["moth_antennae"] = "Plain"
+ target.dna.features["moth_markings"] = "None"
+ target.dna.features["moth_wings"] = "Plain"
+ target.dna.features["snout"] = "Round"
+ target.dna.features["spines"] = "None"
+ target.dna.features["tail_cat"] = "None"
+ target.dna.features["tail_lizard"] = "Smooth"
+ target.dna.features["pod_hair"] = "Ivy"
+
+/// 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()
+ create_consistent_human_dna(src)
+
+/// Provides a dummy for unit_tests that functions like a normal human, but with a standardized appearance
+/// Copies the stock dna setup from the dummy/consistent type
+/mob/living/carbon/human/consistent
+
+/mob/living/carbon/human/consistent/setup_human_dna()
+ create_dna()
+ create_consistent_human_dna(src)
+
+/mob/living/carbon/human/consistent/update_body(is_creating)
+ ..()
+ if(is_creating)
+ fully_replace_character_name(real_name, "John Doe")
+
//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_defines.dm b/code/modules/mob/living/carbon/human/human_defines.dm
index 3051debb2b19..8c986f207a5d 100644
--- a/code/modules/mob/living/carbon/human/human_defines.dm
+++ b/code/modules/mob/living/carbon/human/human_defines.dm
@@ -35,7 +35,6 @@
var/socks = "Nude" //Which socks the player wants
var/backbag = DBACKPACK //Which backpack type the player has chosen.
var/jumpsuit_style = PREF_SUIT //suit/skirt
- var/id_in_pda = FALSE //Whether the player wants their ID to start in their PDA
//Equipment slots
var/obj/item/clothing/wear_suit = null
diff --git a/code/modules/mob/living/carbon/human/human_helpers.dm b/code/modules/mob/living/carbon/human/human_helpers.dm
index 3f6ddf10adf6..832525a00adc 100644
--- a/code/modules/mob/living/carbon/human/human_helpers.dm
+++ b/code/modules/mob/living/carbon/human/human_helpers.dm
@@ -184,7 +184,7 @@
/// When we're joining the game in [/mob/dead/new_player/proc/create_character], we increment our scar slot then store the slot in our mind datum.
/mob/living/carbon/human/proc/increment_scar_slot()
var/check_ckey = ckey || client?.ckey
- if(!check_ckey || !mind || !client?.prefs.persistent_scars)
+ if(!check_ckey || !mind || !client?.prefs.read_preference(/datum/preference/toggle/persistent_scars))
return
var/path = "data/player_saves/[check_ckey[1]]/[check_ckey]/scars.sav"
@@ -229,7 +229,7 @@
/// Read all the scars we have for the designated character/scar slots, verify they're good/dump them if they're old/wrong format, create them on the user, and write the scars that passed muster back to the file
/mob/living/carbon/human/proc/load_persistent_scars()
- if(!ckey || !mind?.original_character_slot_index || !client?.prefs.persistent_scars)
+ if(!ckey || !mind?.original_character_slot_index || !client?.prefs.read_preference(/datum/preference/toggle/persistent_scars))
return
var/path = "data/player_saves/[ckey[1]]/[ckey]/scars.sav"
@@ -255,7 +255,7 @@
/// Save any scars we have to our designated slot, then write our current slot so that the next time we call [/mob/living/carbon/human/proc/increment_scar_slot] (the next round we join), we'll be there
/mob/living/carbon/human/proc/save_persistent_scars(nuke=FALSE)
- if(!ckey || !mind?.original_character_slot_index || !client?.prefs.persistent_scars)
+ if(!ckey || !mind?.original_character_slot_index || !client?.prefs.read_preference(/datum/preference/toggle/persistent_scars))
return
var/path = "data/player_saves/[ckey[1]]/[ckey]/scars.sav"
@@ -286,3 +286,14 @@
/mob/living/carbon/human/proc/get_punchstunthreshold() //Gets the total punch damage needed to knock down someone
return dna.species.punchstunthreshold + physiology.punchstunthreshold_bonus
+
+/// 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/inventory.dm b/code/modules/mob/living/carbon/human/inventory.dm
index ea66c0d76044..edfa33ebc12f 100644
--- a/code/modules/mob/living/carbon/human/inventory.dm
+++ b/code/modules/mob/living/carbon/human/inventory.dm
@@ -257,9 +257,9 @@
else
O = outfit
if(!istype(O))
- return 0
+ return FALSE
if(!O)
- return 0
+ return FALSE
return O.equip(src, visualsOnly)
diff --git a/code/modules/mob/living/carbon/human/species.dm b/code/modules/mob/living/carbon/human/species.dm
index 71effd47c12d..945a0cf50292 100644
--- a/code/modules/mob/living/carbon/human/species.dm
+++ b/code/modules/mob/living/carbon/human/species.dm
@@ -3,6 +3,9 @@
GLOBAL_LIST_EMPTY(roundstart_races)
GLOBAL_LIST_EMPTY(mentor_races)
+/// An assoc list of species types to their features (from get_features())
+GLOBAL_LIST_EMPTY(features_by_species)
+
/datum/species
/// if the game needs to manually check your race to do something not included in a proc here, it will use this
var/id
@@ -10,6 +13,9 @@ GLOBAL_LIST_EMPTY(mentor_races)
var/limbs_id
/// this is the fluff name. these will be left generic (such as 'Lizardperson' for the lizard race) so servers can change them to whatever
var/name
+ /// 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
/// if alien colors are disabled, this is the color that will be used by that race
var/default_color = "#FFF"
/// whether or not the race has sexual characteristics. at the moment this is only FALSE for skeletons and shadows
@@ -31,7 +37,7 @@ GLOBAL_LIST_EMPTY(mentor_races)
var/use_skintones = FALSE
/// If your race wants to bleed something other than bog standard blood, change this to reagent id.
- var/exotic_blood = ""
+ var/datum/reagent/exotic_blood
///If your race uses a non standard bloodtype (A+, O-, AB-, etc)
var/exotic_bloodtype = ""
///What the species drops on gibbing
@@ -196,24 +202,49 @@ GLOBAL_LIST_EMPTY(mentor_races)
/datum/species/New()
-
if(!limbs_id) //if we havent set a limbs id to use, just use our own id
limbs_id = id
- ..()
+
+ 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
+/**
+ * Generates species available to choose in character setup at roundstart
+ *
+ * This proc generates which species are available to pick from in character setup.
+ * If there are no available roundstart species, defaults to human.
+ */
/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)
- else if(S.check_mentor())
- GLOB.mentor_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 += "human"
+
+ return selectable_species
+/**
+ * Checks if a species is eligible to be picked at roundstart.
+ *
+ * Checks the config to see if this species is allowed to be picked in the character setup menu.
+ * Used by [/proc/generate_selectable_species].
+ */
/datum/species/proc/check_roundstart_eligible()
if(id in (CONFIG_GET(keyed_list/roundstart_races)))
return TRUE
@@ -2328,3 +2359,402 @@ GLOBAL_LIST_EMPTY(mentor_races)
to_store += mutanttail
//We don't cache mutant hands because it's not constrained enough, too high a potential for failure
return to_store
+
+/// Returns a list of strings representing features this species has.
+/// Used by the preferences UI to know what buttons to show.
+/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.savefile_key
+
+ /*for (var/obj/item/organ/external/organ_type as anything in external_organs)
+ var/preference = initial(organ_type.preference)
+ if (!isnull(preference))
+ features += preference*/
+
+ 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(liked_food, food_flags),
+ "disliked_food" = bitfield_to_list(disliked_food, food_flags),
+ "toxic_food" = bitfield_to_list(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.
+ listclearnulls(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.",
+ ))
+
+ 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.",
+ ))
+
+ 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.",
+ ))
+
+ // Shock damage
+ 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.",
+ ))
+
+ 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 readded, 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_EASILY_WOUNDED in inherent_traits)
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "user-times",
+ SPECIES_PERK_NAME = "Easily Wounded",
+ SPECIES_PERK_DESC = "[plural_form] skin is very weak and fragile. They are much easier to apply serious wounds to.",
+ ))
+
+ 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!",
+ ))
+
+ 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
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 62eda7a37c9b..5a761f5cb804 100644
--- a/code/modules/mob/living/carbon/human/species_types/IPC.dm
+++ b/code/modules/mob/living/carbon/human/species_types/IPC.dm
@@ -96,6 +96,30 @@ datum/species/ipc/on_species_loss(mob/living/carbon/C)
C.dna.features["ipc_screen"] = null //Turns off screen on death
C.update_body()
+/datum/species/ipc/get_species_description()
+ return /*"IPCs, or Integrated Posibrain Chassis, are a series of constructed bipedal humanoids which vaguely represent humans in their figure. \
+ IPCs were made by several human corporations after the second generation of cyborg units was created."*/
+
+/datum/species/ipc/get_species_lore()
+ return list("TBD",/*
+ "The development and creation of IPCs was a natural occurrence after Sol Interplanetary Coalition explorers, flying a Martian flag, uncovered MMI technology in 2419. \
+ It was massively hoped by scientists, explorers, and opportunists that this discovery would lead to a breakthrough in humanity’s ability to access and understand much of the derelict technology left behind."
+ */)
+
+/datum/species/ipc/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
+
+/datum/species/ipc/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
+
/datum/action/innate/change_screen
name = "Change Display"
check_flags = AB_CHECK_CONSCIOUS
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 4bc1a41367db..44debbb969c5 100644
--- a/code/modules/mob/living/carbon/human/species_types/dullahan.dm
+++ b/code/modules/mob/living/carbon/human/species_types/dullahan.dm
@@ -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 e2ef4a778655..98b45f988178 100644
--- a/code/modules/mob/living/carbon/human/species_types/ethereal.dm
+++ b/code/modules/mob/living/carbon/human/species_types/ethereal.dm
@@ -34,6 +34,7 @@
hair_color = "fixedmutcolor"
hair_alpha = 140
swimming_component = /datum/component/swimming/ethereal
+
var/current_color
var/EMPeffect = FALSE
var/emageffect = FALSE
@@ -60,6 +61,7 @@
. = ..()
if(!ishuman(C))
return
+
var/mob/living/carbon/human/ethereal = C
default_color = "#[ethereal.dna.features["ethcolor"]]"
r1 = GETREDPART(default_color)
@@ -196,3 +198,48 @@
if(istype(stomach))
return stomach.crystal_charge
return ETHEREAL_CHARGE_NONE
+
+/datum/species/ethereal/get_features()
+ var/list/features = ..()
+
+ features += "feature_ethcolor"
+
+ return features
+
+/datum/species/ethereal/get_species_description()
+ return /*"Coming from the planet of Sprout, the theocratic ethereals are \
+ separated socially by caste, and espouse a dogma of aiding the weak and \
+ downtrodden."*/
+
+/datum/species/ethereal/get_species_lore()
+ return list("TBD",/*
+ "Ethereals are a species native to the planet Sprout. \
+ When they were originally discovered, they were at a medieval level of technological progression, \
+ but due to their natural acclimation with electricity, they felt easy among the large NanoTrasen installations.",
+ */)
+
+/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 = "bolt",
+ SPECIES_PERK_NAME = "Shockingly Tasty",
+ SPECIES_PERK_DESC = "Ethereals can feed on electricity from APCs, and do not otherwise need to eat.",
+ ),
+ 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 276e05f02576..ae52c1e5b82c 100644
--- a/code/modules/mob/living/carbon/human/species_types/felinid.dm
+++ b/code/modules/mob/living/carbon/human/species_types/felinid.dm
@@ -171,7 +171,7 @@
/datum/species/human/felinid/spec_life(mob/living/carbon/human/H)
. = ..()
- if((H.client && H.client.prefs.mood_tail_wagging) && !is_wagging_tail() && H.mood_enabled)
+ if((H.client && H.client.prefs.read_preference(/datum/preference/toggle/mood_tail_wagging)) && !is_wagging_tail() && H.mood_enabled)
var/datum/component/mood/mood = H.GetComponent(/datum/component/mood)
if(!istype(mood) || !(mood.shown_mood >= MOOD_LEVEL_HAPPY2))
return
@@ -193,3 +193,32 @@
H.emote("wag")
if(-1)
stop_wagging_tail(H)
+
+/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.",
+ )
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 4ccd49169019..100698775049 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 = "Flyperson"
+ plural_form = "Flypeople"
id = "fly"
say_mod = "buzzes"
species_traits = list(NOEYESPRITES, HAS_FLESH, HAS_BONE)
@@ -31,3 +32,51 @@
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.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "briefcase-medical",
+ SPECIES_PERK_NAME = "Weird Organs",
+ SPECIES_PERK_DESC = "Flypeople take specialized medical knowledge to be \
+ treated. Their organs are disfigured and organ manipulation can be interesting...",
+ ),
+ )
+
+ 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 ccf8569f0e58..d3f413cbdc0f 100644
--- a/code/modules/mob/living/carbon/human/species_types/golems.dm
+++ b/code/modules/mob/living/carbon/human/species_types/golems.dm
@@ -44,6 +44,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
@@ -1548,3 +1563,46 @@
/obj/item/melee/supermatter_sword/hand/Initialize(mapload,silent,synthetic)
. = ..()
ADD_TRAIT(src, TRAIT_NODROP, INNATE_TRAIT)
+
+
+/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 = "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
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 069c9192552a..32f0823d2055 100644
--- a/code/modules/mob/living/carbon/human/species_types/humans.dm
+++ b/code/modules/mob/living/carbon/human/species_types/humans.dm
@@ -29,3 +29,54 @@
if(prob(1))
return 'sound/voice/human/wilhelm_scream.ogg'
return pick(male_screams)
+
+/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("TBD",/*
+ "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.",
+
+ "In traditional human fashion, this near-record pace from terra firma to the final frontier spat \
+ in the face of other races they now shared a stage with. \
+ This included the lizards - if anyone was offended by these upstarts, it was certainly lizardkind.",
+
+ "Humanity never managed to find the kind of peace to fully unite under one banner like other species. \
+ The pencil and paper pushing of the UN bureaucrat lives on in the mosaic that is TerraGov; \
+ a composite of the nation-states that still live on in human society.",
+
+ "The human spirit of opportunity and enterprise continues on in its peak form: \
+ the hypercorporation. Acting outside of TerraGov's influence, literally and figuratively, \
+ hypercorporations buy the senate votes they need and establish territory far past the Earth Government's reach. \
+ In hypercorporation territory company policy is law, giving new meaning to \"employee termination\".",
+ */)
+
+/datum/species/human/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ if(CONFIG_GET(number/default_laws) == 0) // Default lawset is set to Asimov
+ 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, by default, subservient only \
+ to humans. As a human, silicons are required to both protect and obey you.",
+ ))
+
+ 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 Captain and Head of Personel. In addition to this, humans get more than other species.",
+ ))
+
+ 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 a48e4a53db86..71391ce6c4da 100644
--- a/code/modules/mob/living/carbon/human/species_types/jellypeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/jellypeople.dm
@@ -1,6 +1,7 @@
/datum/species/jelly
// Entirely alien beings that seem to be made entirely out of gel. They have three eyes and a skeleton visible within them.
name = "Jellyperson"
+ plural_form = "Jellypeople"
id = "jelly"
default_color = "00FF90"
say_mod = "chirps"
@@ -67,6 +68,21 @@
qdel(consumed_limb)
H.blood_volume += 20
+// Slimes have both NOBLOOD and an exotic bloodtype set, so they need to be handled uniquely here.
+// They may not be roundstart but in the unlikely event they become one might as well not leave a glaring issue open.
+/datum/species/jelly/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)]! This means they will heal from toxin damage. \
+ Jelly is extremely important, as losing it will cause you to lose limbs. Having low jelly will make medical treatment very difficult.",
+ ))
+
+ return to_add
+
/datum/action/innate/regenerate_limbs
name = "Regenerate Limbs"
check_flags = AB_CHECK_CONSCIOUS
@@ -112,6 +128,7 @@
/datum/species/jelly/slime
name = "Slimeperson"
+ plural_form = "Slimepeople"
id = "slime"
default_color = "00FFFF"
species_traits = list(MUTCOLORS,EYECOLOR,HAIR,FACEHAIR,NOBLOOD)
@@ -384,6 +401,7 @@
/datum/species/jelly/luminescent
name = "Luminescent"
+ plural_form = null
id = "lum"
say_mod = "says"
var/glow_intensity = LUMINESCENT_DEFAULT_GLOW
@@ -553,6 +571,7 @@
/datum/species/jelly/stargazer
name = "Stargazer"
+ plural_form = null
id = "stargazer"
var/datum/action/innate/project_thought/project_thought
var/datum/action/innate/link_minds/link_minds
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 e99ced8f28b8..c71e102b3a0e 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 = "Lizardperson"
+ plural_form = "Lizardfolk"
id = "lizard"
say_mod = "hisses"
default_color = "00FF00"
@@ -66,7 +67,7 @@
/datum/species/lizard/spec_life(mob/living/carbon/human/H)
. = ..()
- if((H.client && H.client.prefs.mood_tail_wagging) && !is_wagging_tail() && H.mood_enabled)
+ if((H.client && H.client.prefs.read_preference(/datum/preference/toggle/mood_tail_wagging)) && !is_wagging_tail() && H.mood_enabled)
var/datum/component/mood/mood = H.GetComponent(/datum/component/mood)
if(!istype(mood) || !(mood.shown_mood >= MOOD_LEVEL_HAPPY2))
return
@@ -89,6 +90,46 @@
if(-1)
stop_wagging_tail(H)
+/datum/species/lizard/get_species_description()
+ return /*"The militaristic Lizardpeople hail originally from Tizira, but have grown \
+ throughout their centuries in the stars to possess a large spacefaring \
+ empire: though now they must contend with their younger, more \
+ technologically advanced Human neighbours."*/
+
+/datum/species/lizard/get_species_lore()
+ return list(
+ "TBD",/*
+ "The face of conspiracy theory was changed forever the day mankind met the lizards.",
+
+ "Hailing from the arid world of Tizira, lizards were travelling the stars back when mankind was first discovering how neat trains could be. \
+ However, much like the space-fable of the space-tortoise and space-hare, lizards have rejected their kin's motto of \"slow and steady\" \
+ in favor of resting on their laurels and getting completely surpassed by 'bald apes', due in no small part to their lack of access to plasma.",
+
+ "The history between lizards and humans has resulted in many conflicts that lizards ended on the losing side of, \
+ with the finale being an explosive remodeling of their moon. Today's lizard-human relations are seeing the continuance of a record period of peace.",
+
+ "Lizard culture is inherently militaristic, though the influence the military has on lizard culture \
+ begins to lessen the further colonies lie from their homeworld - \
+ with some distanced colonies finding themselves subsumed by the cultural practices of other species nearby.",
+
+ "On their homeworld, lizards celebrate their 16th birthday by enrolling in a mandatory 5 year military tour of duty. \
+ Roles range from combat to civil service and everything in between. As the old slogan goes: \"Your place will be found!\"",
+ */)
+
+// Override for the default temperature perks, so we can give our specific "cold blooded" perk.
+/datum/species/lizard/create_pref_temperature_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "thermometer-empty",
+ SPECIES_PERK_NAME = "Cold-blooded",
+ SPECIES_PERK_DESC = "Lizardpeople have higher tolerance for hot temperatures, but lower \
+ tolerance for cold temperatures. Additionally, they cannot self-regulate their body temperature - \
+ they are as cold or as warm as the environment around them is. Stay warm!",
+ ))
+
+ return to_add
/*
Lizard subspecies: ASHWALKERS
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 f3efed0db1d6..8d4dedb9c9ff 100644
--- a/code/modules/mob/living/carbon/human/species_types/mothmen.dm
+++ b/code/modules/mob/living/carbon/human/species_types/mothmen.dm
@@ -1,5 +1,6 @@
/datum/species/moth
name = "Mothperson"
+ plural_form = "Mothpeople"
id = "moth"
say_mod = "flutters"
default_color = "00FF00"
@@ -71,3 +72,57 @@
var/datum/gas_mixture/current = H.loc.return_air()
if(current && (current.return_pressure() >= ONE_ATMOSPHERE*0.85)) //as long as there's reasonable pressure and no gravity, flight is possible
return TRUE
+
+/datum/species/moth/get_species_description()
+ return /*"Hailing from a planet that was lost long ago, the moths travel \
+ the galaxy as a nomadic people aboard a colossal fleet of ships, seeking a new homeland."*/
+
+/datum/species/moth/get_species_lore()
+ return list(/*
+ "Their homeworld lost to the ages, the moths live aboard the Grand Nomad Fleet. \
+ Made up of what could be found, bartered, repaired, or stolen the armada is a colossal patchwork \
+ built on a history of politely flagging travelers down and taking their things. Occasionally a moth \
+ will decide to leave the fleet, usually to strike out for fortunes to send back home.",
+
+ "Nomadic life produces a tight-knit culture, with moths valuing their friends, family, and vessels highly. \
+ Moths are gregarious by nature and do best in communal spaces. This has served them well on the galactic stage, \
+ maintaining a friendly and personable reputation even in the face of hostile encounters. \
+ It seems that the galaxy has come to accept these former pirates.",
+
+ "Surprisingly, living together in a giant fleet hasn't flattened variance in dialect and culture. \
+ These differences are welcomed and encouraged within the fleet for the variety that they bring.",
+ */)
+
+/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.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK,
+ SPECIES_PERK_ICON = "sun",
+ SPECIES_PERK_NAME = "Bright Lights",
+ SPECIES_PERK_DESC = "Moths need an extra layer of flash protection to protect \
+ themselves, such as against security officers or when welding. Welding \
+ masks will work.",
+ ),
+ )
+
+ return to_add
diff --git a/code/modules/mob/living/carbon/human/species_types/mushpeople.dm b/code/modules/mob/living/carbon/human/species_types/mushpeople.dm
index 13761a2d60b5..3087a3c397e6 100644
--- a/code/modules/mob/living/carbon/human/species_types/mushpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/mushpeople.dm
@@ -1,5 +1,6 @@
/datum/species/mush //mush mush codecuck
name = "Mushroomperson"
+ plural_form = "Mushroompeople"
id = "mush"
mutant_bodyparts = list("caps")
default_features = list("caps" = "Round")
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 412554b7c997..aa8a5e99dc6d 100644
--- a/code/modules/mob/living/carbon/human/species_types/plasmamen.dm
+++ b/code/modules/mob/living/carbon/human/species_types/plasmamen.dm
@@ -1,5 +1,6 @@
/datum/species/plasmaman
name = "Plasmaman"
+ plural_form = "Plasmamen"
id = "plasmaman"
say_mod = "rattles"
sexes = FALSE
@@ -8,7 +9,8 @@
// plasmemes get hard to wound since they only need a severe bone wound to dismember, but unlike skellies, they can't pop their bones back into place.
inherent_traits = list(TRAIT_RESISTCOLD,TRAIT_RADIMMUNE,TRAIT_GENELESS,TRAIT_NOHUNGER,TRAIT_CALCIUM_HEALER,TRAIT_ALWAYS_CLEAN,TRAIT_HARDLY_WOUNDED)
inherent_biotypes = list(MOB_UNDEAD, MOB_HUMANOID)
- default_features = list("plasmaman_helmet")
+ mutant_bodyparts = list("plasmaman_helmet")
+ default_features = list("plasmaman_helmet" = "None")
mutantlungs = /obj/item/organ/lungs/plasmaman
mutanttongue = /obj/item/organ/tongue/bone/plasmaman
mutantliver = /obj/item/organ/liver/plasmaman
@@ -22,7 +24,6 @@
payday_modifier = 1 //Former humans, employment restrictions arise from psychological and practical concerns; they won't be able to be some head positions, but they get human pay and NT can't weasel out of it
breathid = "tox"
damage_overlay_type = ""//let's not show bloody wounds or burns over bones.
- var/internal_fire = FALSE //If the bones themselves are burning clothes won't help you much
disliked_food = NONE
liked_food = DAIRY
changesource_flags = MIRROR_BADMIN | WABBAJACK | MIRROR_PRIDE | MIRROR_MAGIC
@@ -30,6 +31,9 @@
smells_like = "plasma-caked calcium"
+ /// If the bones themselves are burning clothes won't help you much
+ var/internal_fire = FALSE
+
/datum/species/plasmaman/spec_life(mob/living/carbon/human/H)
var/datum/gas_mixture/environment = H.loc.return_air()
var/atmos_sealed = FALSE
@@ -175,8 +179,6 @@
var/obj/item/clothing/head/helmet/space/plasmaman/plasmeme_helmet = H.head
plasmeme_helmet.set_design(H)
- return 0
-
/datum/species/plasmaman/random_name(gender,unique,lastname)
if(unique)
return random_unique_plasmaman_name()
@@ -187,3 +189,80 @@
randname += " [lastname]"
return randname
+
+/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(
+ "TBD",/*
+ "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 = "bone",
+ SPECIES_PERK_NAME = "Wound Resistance",
+ SPECIES_PERK_DESC = "Plasmamen have higher tolerance for damage that would wound others.",
+ ),
+ list(
+ SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK,
+ SPECIES_PERK_ICON = "wind",
+ SPECIES_PERK_NAME = "Plasma Healing",
+ SPECIES_PERK_DESC = "Plasmamen can heal wounds by consuming plasma.",
+ ),
+ 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 8dd82d3d2d8e..4b1810dc2b71 100644
--- a/code/modules/mob/living/carbon/human/species_types/podpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/podpeople.dm
@@ -7,6 +7,7 @@ DISREGUARD THIS FILE IF YOU'RE INTENDING TO CHANGE ASPECTS OF PLAYER CONTROLLED
/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 = "Podperson"
+ plural_form = "Podpeople"
id = "pod"
default_color = "59CE00"
species_traits = list(MUTCOLORS,EYECOLOR)
diff --git a/code/modules/mob/living/carbon/human/species_types/polysmorphs.dm b/code/modules/mob/living/carbon/human/species_types/polysmorphs.dm
index 2ea0e1fc6620..9447592a23e4 100644
--- a/code/modules/mob/living/carbon/human/species_types/polysmorphs.dm
+++ b/code/modules/mob/living/carbon/human/species_types/polysmorphs.dm
@@ -52,3 +52,25 @@
.=..()
if(C.physiology)
C.physiology.armor.wound -= 10
+
+/datum/species/polysmorph/get_species_description()
+ return ""//"TODO: This is polysmorph description"
+
+/datum/species/polysmorph/get_species_lore()
+ return list(
+ ""//"TODO: This is polysmorph lore"
+ )
+
+/datum/species/polysmorph/create_pref_unique_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
+
+/datum/species/polysmorph/create_pref_biotypes_perks()
+ var/list/to_add = list()
+
+ // TODO
+
+ return to_add
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 eb9aaae2f961..0b68ce850ecc 100644
--- a/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm
+++ b/code/modules/mob/living/carbon/human/species_types/shadowpeople.dm
@@ -4,6 +4,7 @@
/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 = "???"
+ plural_form = "???"
id = "shadow"
sexes = FALSE
ignored_by = list(/mob/living/simple_animal/hostile/faithless)
@@ -30,8 +31,58 @@
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"
+ plural_form = null
id = "nightmare"
limbs_id = "shadow"
burnmod = 1.5
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 85cd0cce1b33..6bf2eb266c27 100644
--- a/code/modules/mob/living/carbon/human/species_types/skeletons.dm
+++ b/code/modules/mob/living/carbon/human/species_types/skeletons.dm
@@ -28,3 +28,15 @@
if(SSevents.holidays && SSevents.holidays[HALLOWEEN])
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 c53f045b9e7c..af55a09bcdb0 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 = "Snailperson"
+ plural_form = "Snailpeople"
id = "snail"
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/vampire.dm b/code/modules/mob/living/carbon/human/species_types/vampire.dm
index a16bad18939e..5a5f6c407ba5 100644
--- a/code/modules/mob/living/carbon/human/species_types/vampire.dm
+++ b/code/modules/mob/living/carbon/human/species_types/vampire.dm
@@ -60,8 +60,80 @@
/datum/species/vampire/check_species_weakness(obj/item/weapon, mob/living/attacker)
if(istype(weapon, /obj/item/nullrod/whip))
- return 1 //Whips deal 2x damage to vampires. Vampire killer.
- return 0
+ return TRUE //Whips deal 2x damage to vampires. Vampire killer.
+
+ return FALSE
+
+/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. \
+ Because of this, Vampires have split into two clans, one that embraces their powers as a blessing and one that rejects it.",
+ )
+
+/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_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "book-dead",
+ SPECIES_PERK_NAME = "Vampire Clans",
+ SPECIES_PERK_DESC = "Vampires belong to one of two clans - the Inoculated, and the Outcast. The Outcast \
+ don't follow many vampiric traditions, while the Inoculated are given unique names and flavor.",
+ ),
+ 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"
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 15a89a60aa37..bc7ac579f7b3 100644
--- a/code/modules/mob/living/carbon/human/species_types/zombies.dm
+++ b/code/modules/mob/living/carbon/human/species_types/zombies.dm
@@ -21,6 +21,28 @@
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.")
+
+// Override for the default temperature perks, so we can establish that they don't care about temperature very much
+/datum/species/zombie/create_pref_temperature_perks()
+ var/list/to_add = list()
+
+ to_add += list(list(
+ SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK,
+ SPECIES_PERK_ICON = "thermometer-half",
+ SPECIES_PERK_NAME = "No Body Temperature",
+ SPECIES_PERK_DESC = "Having long since departed, Zombies do not have anything \
+ regulating their body temperature anymore. This means that \
+ the environment decides their body temperature - which they don't mind at \
+ all, until it gets a bit too hot.",
+ ))
+
+ return to_add
+
/datum/species/zombie/infectious
name = "Infectious Zombie"
id = "memezombies"
diff --git a/code/modules/mob/living/say.dm b/code/modules/mob/living/say.dm
index baea19d42527..c17577de1d63 100644
--- a/code/modules/mob/living/say.dm
+++ b/code/modules/mob/living/say.dm
@@ -266,7 +266,7 @@ GLOBAL_LIST_INIT(special_radio_keys, list(
deaf_message = span_notice("You can't hear yourself!")
deaf_type = 2 // Since you should be able to hear yourself without looking
// Create map text prior to modifying message for goonchat
- if (client?.prefs.chat_on_map && stat != UNCONSCIOUS && (client.prefs.see_chat_non_mob || ismob(speaker)) && can_hear())
+ if (client?.prefs.read_preference(/datum/preference/toggle/enable_runechat) && stat != UNCONSCIOUS && (client.prefs.read_preference(/datum/preference/toggle/enable_runechat_non_mobs) || ismob(speaker)) && can_hear())
create_chat_message(speaker, message_language, raw_message, spans)
diff --git a/code/modules/mob/living/silicon/ai/ai.dm b/code/modules/mob/living/silicon/ai/ai.dm
index dcf454152e6e..1da9dc9e8f35 100644
--- a/code/modules/mob/living/silicon/ai/ai.dm
+++ b/code/modules/mob/living/silicon/ai/ai.dm
@@ -166,7 +166,7 @@
create_eye()
if(client)
- apply_pref_name("ai",client)
+ INVOKE_ASYNC(src, .proc/apply_pref_name, /datum/preference/name/ai, client)
INVOKE_ASYNC(src, .proc/set_core_display_icon)
@@ -243,13 +243,13 @@
/mob/living/silicon/ai/proc/set_core_display_icon(input, client/C)
if(client && !C)
C = client
- if(!input && !C?.prefs?.preferred_ai_core_display)
+ if(!input && !C?.prefs?.read_preference(/datum/preference/choiced/ai_core_display))
for (var/each in GLOB.ai_core_displays) //change status of displays
var/obj/machinery/status_display/ai_core/M = each
M.set_ai(initial(icon_state))
M.update()
else
- var/preferred_icon = input ? input : C.prefs.preferred_ai_core_display
+ var/preferred_icon = input ? input : C.prefs.read_preference(/datum/preference/choiced/ai_core_display)
icon = initial(icon) //yogs
for (var/each in GLOB.ai_core_displays) //change status of displays
@@ -974,7 +974,7 @@
jobpart = "Unknown"
var/rendered = "[start][hrefpart][namepart] ([jobpart]) [span_message("[treated_message]")] "
- if (client?.prefs.chat_on_map && (client.prefs.see_chat_non_mob || ismob(speaker)))
+ if (client?.prefs.read_preference(/datum/preference/toggle/enable_runechat) && (client.prefs.read_preference(/datum/preference/toggle/enable_runechat_non_mobs) || ismob(speaker)))
create_chat_message(speaker, message_language, raw_message, spans)
show_message(rendered, 2)
diff --git a/code/modules/mob/living/silicon/ai/ai_portrait_picker.dm b/code/modules/mob/living/silicon/ai/ai_portrait_picker.dm
index 9700d463b499..a38a47131fb6 100644
--- a/code/modules/mob/living/silicon/ai/ai_portrait_picker.dm
+++ b/code/modules/mob/living/silicon/ai/ai_portrait_picker.dm
@@ -34,7 +34,7 @@
/datum/portrait_picker/ui_data(mob/user)
var/list/data = list()
- data["public_paintings"] = SSpersistence.paintings["public"] ? SSpersistence.paintings["public"] : 0
+ data["public_paintings"] = SSpersistent_paintings.paintings["public"] ? SSpersistent_paintings.paintings["public"] : 0
return data
/datum/portrait_picker/ui_act(action, params)
@@ -45,7 +45,7 @@
if("select")
var/list/tab2key = list(TAB_PUBLIC = "public")
var/folder = tab2key[params["tab"]]
- var/list/current_list = SSpersistence.paintings[folder]
+ var/list/current_list = SSpersistent_paintings.paintings[folder]
var/list/chosen_portrait = current_list[params["selected"]]
var/png = "data/paintings/[folder]/[chosen_portrait["md5"]].png"
var/icon/portrait_icon = new(png)
diff --git a/code/modules/mob/living/silicon/robot/robot.dm b/code/modules/mob/living/silicon/robot/robot.dm
index 0749c123207d..23e71385c2e1 100644
--- a/code/modules/mob/living/silicon/robot/robot.dm
+++ b/code/modules/mob/living/silicon/robot/robot.dm
@@ -261,9 +261,9 @@
var/changed_name = ""
if(custom_name)
changed_name = custom_name
- if(changed_name == "" && C && C.prefs.custom_names["cyborg"] != DEFAULT_CYBORG_NAME)
- if(apply_pref_name("cyborg", C))
- return //built in camera handled in proc
+ if(changed_name == "" && C && C.prefs.read_preference(/datum/preference/name/cyborg) != DEFAULT_CYBORG_NAME)
+ apply_pref_name(/datum/preference/name/cyborg, C)
+ return
if(!changed_name)
changed_name = get_standard_name()
diff --git a/code/modules/mob/living/silicon/silicon.dm b/code/modules/mob/living/silicon/silicon.dm
index 145259aa7c52..d6931bfc28a0 100644
--- a/code/modules/mob/living/silicon/silicon.dm
+++ b/code/modules/mob/living/silicon/silicon.dm
@@ -471,7 +471,7 @@
var/datum/mind/mega = usr.mind
if(!istype(mega))
return
- var/aksent = input(usr, "Choose your accent:","Available Accents") as null|anything in (assoc_list_strip_value(strings("accents.json", "accent_file_names", directory = "strings/accents")) + "None")
+ var/aksent = input(usr, "Choose your accent:","Available Accents") as null|anything in (assoc_to_keys(strings("accents.json", "accent_file_names", directory = "strings/accents")) + "None")
if(aksent) // Accents were an accidents why the fuck do I have to do mind.RegisterSignal(mob, COMSIG_MOB_SAY)
if(aksent == "None")
mega.accent_name = null
diff --git a/code/modules/mob/living/simple_animal/hostile/zombie.dm b/code/modules/mob/living/simple_animal/hostile/zombie.dm
index 3871aabee6f9..171935935021 100644
--- a/code/modules/mob/living/simple_animal/hostile/zombie.dm
+++ b/code/modules/mob/living/simple_animal/hostile/zombie.dm
@@ -30,21 +30,20 @@
setup_visuals()
/mob/living/simple_animal/hostile/zombie/proc/setup_visuals()
- var/datum/preferences/dummy_prefs = new
- dummy_prefs.pref_species = new /datum/species/zombie
- dummy_prefs.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/icon/P = get_flat_human_icon("zombie_[zombiejob]", J , dummy_prefs, "zombie", outfit_override = O)
- icon = P
+ 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)
+ icon = getFlatIcon(dummy)
+ qdel(dummy)
+
corpse = new(src)
- corpse.outfit = O
+ corpse.outfit = outfit
corpse.mob_species = /datum/species/zombie
corpse.mob_name = name
@@ -59,4 +58,4 @@
corpse.create()
/mob/living/simple_animal/hostile/zombie/mostlyinfection //yogs 25% infection zombie
- infection_chance = 25
\ No newline at end of file
+ infection_chance = 25
diff --git a/code/modules/mob/login.dm b/code/modules/mob/login.dm
index 2f40f21dcdba..670d303561d1 100644
--- a/code/modules/mob/login.dm
+++ b/code/modules/mob/login.dm
@@ -36,7 +36,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_preference(/datum/preference/choiced/ui_style)))
next_move = 1
@@ -73,7 +73,7 @@
update_client_colour()
update_mouse_pointer()
if(client)
- client.change_view(getScreenSize(client.prefs.widescreenpref))
+ client.change_view(getScreenSize(client.prefs.read_preference(/datum/preference/toggle/widescreen)))
if(client.player_details.player_actions.len)
for(var/datum/action/A in client.player_details.player_actions)
A.Grant(src)
diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm
index ae507e2cf336..64eb236d53fb 100644
--- a/code/modules/mob/mob.dm
+++ b/code/modules/mob/mob.dm
@@ -255,16 +255,18 @@
///Returns the client runechat visible messages preference according to the message type.
/atom/proc/runechat_prefs_check(mob/target, visible_message_flags = NONE)
- if(!target.client?.prefs.chat_on_map || !target.client.prefs.see_chat_non_mob)
+ if(!target.client?.prefs.read_preference(/datum/preference/toggle/enable_runechat))
return FALSE
- if(visible_message_flags & EMOTE_MESSAGE && !target.client.prefs.see_rc_emotes)
+ if (!target.client?.prefs.read_preference(/datum/preference/toggle/enable_runechat_non_mobs))
+ return FALSE
+ if(visible_message_flags & EMOTE_MESSAGE && !target.client.prefs.read_preference(/datum/preference/toggle/see_rc_emotes))
return FALSE
return TRUE
/mob/runechat_prefs_check(mob/target, visible_message_flags = NONE)
- if(!target.client?.prefs.chat_on_map)
+ if(!target.client?.prefs.read_preference(/datum/preference/toggle/enable_runechat))
return FALSE
- if(visible_message_flags & EMOTE_MESSAGE && !target.client.prefs.see_rc_emotes)
+ if(visible_message_flags & EMOTE_MESSAGE && !target.client.prefs.read_preference(/datum/preference/toggle/see_rc_emotes))
return FALSE
return TRUE
diff --git a/code/modules/mob/mob_helpers.dm b/code/modules/mob/mob_helpers.dm
index d75b4b3c9bf4..28a437a7692f 100644
--- a/code/modules/mob/mob_helpers.dm
+++ b/code/modules/mob/mob_helpers.dm
@@ -411,8 +411,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_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 1217b49a9e5a..84979ea01903 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.copy_to(H)
+ client.prefs.apply_prefs_to(H, TRUE)
H.dna.update_dna_identity()
if(mind && isliving(M))
diff --git a/code/modules/mob/transform_procs.dm b/code/modules/mob/transform_procs.dm
index 926e3c20d4b5..11f82fd7841e 100644
--- a/code/modules/mob/transform_procs.dm
+++ b/code/modules/mob/transform_procs.dm
@@ -390,7 +390,7 @@
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 92498ad82fae..3e13cf7a4590 100644
--- a/code/modules/modular_computers/computers/item/computer.dm
+++ b/code/modules/modular_computers/computers/item/computer.dm
@@ -102,13 +102,10 @@
/obj/item/modular_computer/Destroy()
kill_program(forced = TRUE)
STOP_PROCESSING(SSobj, src)
- for(var/H in all_components)
- var/obj/item/computer_hardware/CH = all_components[H]
- if(CH.holder == src)
- CH.on_remove(src)
- CH.holder = null
- all_components.Remove(CH.device_type)
- qdel(CH)
+ for(var/port in all_components)
+ var/obj/item/computer_hardware/component = all_components[port]
+ qdel(component)
+ all_components.Cut() //Die demon die
physical = null
return ..()
diff --git a/code/modules/modular_computers/computers/item/computer_components.dm b/code/modules/modular_computers/computers/item/computer_components.dm
index b328e6701e27..8020167959a6 100644
--- a/code/modules/modular_computers/computers/item/computer_components.dm
+++ b/code/modules/modular_computers/computers/item/computer_components.dm
@@ -1,67 +1,71 @@
-/obj/item/modular_computer/proc/can_install_component(obj/item/computer_hardware/H, mob/living/user = null)
- if(!H.can_install(src, user))
+/obj/item/modular_computer/proc/can_install_component(obj/item/computer_hardware/try_install, mob/living/user = null)
+ if(!try_install.can_install(src, user))
return FALSE
- if(H.w_class > max_hardware_size)
+ if(try_install.w_class > max_hardware_size)
to_chat(user, span_warning("This component is too large for \the [src]!"))
return FALSE
- if(H.expansion_hw)
+ if(try_install.expansion_hw)
if(LAZYLEN(expansion_bays) >= max_bays)
to_chat(user, "All of the computer's expansion bays are filled. ")
return FALSE
- if(LAZYACCESS(expansion_bays, H.device_type))
- to_chat(user, "The computer immediately ejects /the [H] and flashes an error: \"Hardware Address Conflict\". ")
+ if(LAZYACCESS(expansion_bays, try_install.device_type))
+ to_chat(user, "The computer immediately ejects /the [try_install] and flashes an error: \"Hardware Address Conflict\". ")
return FALSE
- if(all_components[H.device_type])
- to_chat(user, span_warning("This computer's hardware slot is already occupied by \the [all_components[H.device_type]]."))
+ if(all_components[try_install.device_type])
+ to_chat(user, span_warning("This computer's hardware slot is already occupied by \the [all_components[try_install.device_type]]."))
return FALSE
return TRUE
-// Installs component.
-/obj/item/modular_computer/proc/install_component(obj/item/computer_hardware/H, mob/living/user = null)
- if(!can_install_component(H, user))
+/// Installs component.
+/obj/item/modular_computer/proc/install_component(obj/item/computer_hardware/install, mob/living/user = null)
+ if(!can_install_component(install, user))
return FALSE
- if(user && !user.transferItemToLoc(H, src))
+ if(user && !user.transferItemToLoc(install, src))
return FALSE
- if(H.expansion_hw)
- LAZYSET(expansion_bays, H.device_type, H)
- all_components[H.device_type] = H
+ if(install.expansion_hw)
+ LAZYSET(expansion_bays, install.device_type, install)
+ all_components[install.device_type] = install
- to_chat(user, span_notice("You install \the [H] into \the [src]."))
- H.holder = src
- H.forceMove(src)
- H.on_install(src, user)
+ to_chat(user, span_notice("You install \the [install] into \the [src]."))
+ install.holder = src
+ install.forceMove(src)
+ install.on_install(src, user)
-// Uninstalls component.
-/obj/item/modular_computer/proc/uninstall_component(obj/item/computer_hardware/H, mob/living/user = null)
- if(H.holder != src) // Not our component at all.
+/// Uninstalls component.
+/obj/item/modular_computer/proc/uninstall_component(obj/item/computer_hardware/yeet, mob/living/user = null)
+ if(yeet.holder != src) // Not our component at all.
return FALSE
- if(H.expansion_hw)
- LAZYREMOVE(expansion_bays, H.device_type)
- all_components.Remove(H.device_type)
+ to_chat(user, span_notice("You remove \the [yeet] from \the [src]."))
- to_chat(user, span_notice("You remove \the [H] from \the [src]."))
-
- H.forceMove(get_turf(src))
- H.holder = null
- H.on_remove(src, user)
+ yeet.forceMove(get_turf(src))
+ forget_component(yeet)
+ yeet.on_remove(src, user)
if(enabled && !use_power())
shutdown_computer()
update_icon()
return TRUE
+/// This isn't the "uninstall fully" proc, it just makes the computer lose all its references to the component
+/obj/item/modular_computer/proc/forget_component(obj/item/computer_hardware/wipe_memory)
+ if(wipe_memory.holder != src)
+ return FALSE
+ if(wipe_memory.expansion_hw)
+ LAZYREMOVE(expansion_bays, wipe_memory.device_type)
+ all_components.Remove(wipe_memory.device_type)
+ wipe_memory.holder = null
-// Checks all hardware pieces to determine if name matches, if yes, returns the hardware piece, otherwise returns null
+/// Checks all hardware pieces to determine if name matches, if yes, returns the hardware piece, otherwise returns null
/obj/item/modular_computer/proc/find_hardware_by_name(name)
for(var/i in all_components)
- var/obj/O = all_components[i]
- if(O.name == name)
- return O
+ var/obj/component = all_components[i]
+ if(component.name == name)
+ return component
return null
diff --git a/code/modules/modular_computers/file_system/programs/portrait_printer.dm b/code/modules/modular_computers/file_system/programs/portrait_printer.dm
index e898fffa7acc..912bf4030dcd 100644
--- a/code/modules/modular_computers/file_system/programs/portrait_printer.dm
+++ b/code/modules/modular_computers/file_system/programs/portrait_printer.dm
@@ -22,7 +22,7 @@
/datum/computer_file/program/portrait_printer/ui_data(mob/user)
var/list/data = list()
- data["public_paintings"] = SSpersistence.paintings["public"] ? SSpersistence.paintings["public"] : 0
+ data["public_paintings"] = SSpersistent_paintings.paintings["public"] ? SSpersistent_paintings.paintings["public"] : 0
return data
/datum/computer_file/program/portrait_printer/ui_assets(mob/user)
@@ -50,7 +50,7 @@
//canvas printing!
var/list/tab2key = list(TAB_PUBLIC = "public")
var/folder = tab2key[params["tab"]]
- var/list/current_list = SSpersistence.paintings[folder]
+ var/list/current_list = SSpersistent_paintings.paintings[folder]
var/list/chosen_portrait = current_list[params["selected"]]
var/author = chosen_portrait["author"]
var/title = chosen_portrait["title"]
diff --git a/code/modules/modular_computers/hardware/CPU.dm b/code/modules/modular_computers/hardware/CPU.dm
index addc4fb20151..67e45480f604 100644
--- a/code/modules/modular_computers/hardware/CPU.dm
+++ b/code/modules/modular_computers/hardware/CPU.dm
@@ -13,8 +13,8 @@
var/single_purpose = FALSE // If you can switch to other programs or only use the initial program
device_type = MC_CPU
-/obj/item/computer_hardware/processor_unit/on_remove(obj/item/modular_computer/MC, mob/user)
- MC.shutdown_computer()
+/obj/item/computer_hardware/processor_unit/on_remove(obj/item/modular_computer/remove_from, mob/user)
+ remove_from.shutdown_computer()
/obj/item/computer_hardware/processor_unit/small
name = "microprocessor"
diff --git a/code/modules/modular_computers/hardware/_hardware.dm b/code/modules/modular_computers/hardware/_hardware.dm
index 29da37e09948..8cb9b430951b 100644
--- a/code/modules/modular_computers/hardware/_hardware.dm
+++ b/code/modules/modular_computers/hardware/_hardware.dm
@@ -9,17 +9,29 @@
var/obj/item/modular_computer/holder = null
// Computer that holds this hardware, if any.
- var/power_usage = 0 // If the hardware uses extra power, change this.
- var/enabled = TRUE // If the hardware is turned off set this to 0.
- var/critical = FALSE // Prevent disabling for important component, like the CPU.
- var/can_install = TRUE // Prevents direct installation of removable media.
- var/expansion_hw = FALSE // Hardware that fits into expansion bays.
- var/removable = TRUE // Whether the hardware is removable or not.
- var/damage = 0 // Current damage level
- var/max_damage = 100 // Maximal damage level.
- var/damage_malfunction = 20 // "Malfunction" threshold. When damage exceeds this value the hardware piece will semi-randomly fail and do !!FUN!! things
- var/damage_failure = 50 // "Failure" threshold. When damage exceeds this value the hardware piece will not work at all.
- var/malfunction_probability = 10// Chance of malfunction when the component is damaged
+ /// If the hardware uses extra power, change this.
+ var/power_usage = 0
+ /// If the hardware is turned off set this to 0.
+ var/enabled = TRUE
+ /// Prevent disabling for important component, like the CPU.
+ var/critical = FALSE
+ /// Prevents direct installation of removable media.
+ var/can_install = TRUE
+ /// Hardware that fits into expansion bays.
+ var/expansion_hw = FALSE
+ /// Whether the hardware is removable or not.
+ var/removable = TRUE
+ /// Current damage level
+ var/damage = 0
+ /// Maximal damage level.
+ var/max_damage = 100
+ /// "Malfunction" threshold. When damage exceeds this value the hardware piece will semi-randomly fail and do !!FUN!! things
+ var/damage_malfunction = 20
+ /// "Failure" threshold. When damage exceeds this value the hardware piece will not work at all.
+ var/damage_failure = 50
+ /// Chance of malfunction when the component is damaged
+ var/malfunction_probability = 10
+ /// What define is used to qualify this piece of hardware? Important for upgraded versions of the same hardware.
var/device_type
/obj/item/computer_hardware/New(var/obj/L)
@@ -29,7 +41,7 @@
/obj/item/computer_hardware/Destroy()
if(holder)
- holder.uninstall_component(src)
+ holder.forget_component(src)
return ..()
@@ -56,11 +68,11 @@
to_chat(user, "******************************")
return TRUE
-// Called on multitool click, prints diagnostic information to the user.
+/// Called on multitool click, prints diagnostic information to the user.
/obj/item/computer_hardware/proc/diagnostics(var/mob/user)
to_chat(user, "Hardware Integrity Test... (Corruption: [damage]/[max_damage]) [damage > damage_failure ? "FAIL" : damage > damage_malfunction ? "WARN" : "PASS"]")
-// Handles damage checks
+/// Handles damage checks
/obj/item/computer_hardware/proc/check_functionality()
if(!enabled) // Disabled.
return FALSE
@@ -83,22 +95,23 @@
else if(damage)
. += span_notice("It seems to be slightly damaged.")
-// Component-side compatibility check.
-/obj/item/computer_hardware/proc/can_install(obj/item/modular_computer/M, mob/living/user = null)
+/// Component-side compatibility check.
+/obj/item/computer_hardware/proc/can_install(obj/item/modular_computer/install_into, mob/living/user = null)
return can_install
-// Called when component is installed into PC.
-/obj/item/computer_hardware/proc/on_install(obj/item/modular_computer/M, mob/living/user = null)
+/// Called when component is installed into PC.
+/obj/item/computer_hardware/proc/on_install(obj/item/modular_computer/install_into, mob/living/user = null)
return
-// Called when component is removed from PC.
-/obj/item/computer_hardware/proc/on_remove(obj/item/modular_computer/M, mob/living/user = null)
- try_eject(forced = 1)
+/// Called when component is removed from PC.
+/obj/item/computer_hardware/proc/on_remove(obj/item/modular_computer/remove_from, mob/living/user)
+ if(remove_from.physical && !QDELETED(remove_from) && !QDELETED(src))
+ try_eject(forced = TRUE)
-// Called when someone tries to insert something in it - paper in printer, card in card reader, etc.
+/// Called when someone tries to insert something in it - paper in printer, card in card reader, etc.
/obj/item/computer_hardware/proc/try_insert(obj/item/I, mob/living/user = null)
return FALSE
-// Called when someone tries to eject something from it - card from card reader, etc.
+/// Called when someone tries to eject something from it - card from card reader, etc.
/obj/item/computer_hardware/proc/try_eject(slot=0, mob/living/user = null, forced = 0)
return FALSE
diff --git a/code/modules/modular_computers/hardware/ai_slot.dm b/code/modules/modular_computers/hardware/ai_slot.dm
index 280bb7a69b70..15a458b8b503 100644
--- a/code/modules/modular_computers/hardware/ai_slot.dm
+++ b/code/modules/modular_computers/hardware/ai_slot.dm
@@ -7,13 +7,14 @@
device_type = MC_AI
expansion_hw = TRUE
- var/obj/item/aicard/stored_card = null
+ var/obj/item/aicard/stored_card
var/locked = FALSE
-/obj/item/computer_hardware/ai_slot/handle_atom_del(atom/A)
+///What happens when the intellicard is removed (or deleted) from the module, through try_eject() or not.
+/obj/item/computer_hardware/ai_slot/Exited(atom/A, atom/newloc)
if(A == stored_card)
- try_eject(0, null, TRUE)
- . = ..()
+ stored_card = null
+ return ..()
/obj/item/computer_hardware/ai_slot/examine(mob/user)
. = ..()
@@ -55,7 +56,6 @@
user.put_in_hands(stored_card)
else
stored_card.forceMove(drop_location())
- stored_card = null
return TRUE
return FALSE
diff --git a/code/modules/modular_computers/hardware/battery_module.dm b/code/modules/modular_computers/hardware/battery_module.dm
index f30e598a134b..2350a74ca83d 100644
--- a/code/modules/modular_computers/hardware/battery_module.dm
+++ b/code/modules/modular_computers/hardware/battery_module.dm
@@ -4,22 +4,26 @@
icon_state = "cell_con"
critical = 1
malfunction_probability = 1
- var/obj/item/stock_parts/cell/battery = null
+ var/obj/item/stock_parts/cell/battery
device_type = MC_CELL
-/obj/item/computer_hardware/battery/New(loc, battery_type = null)
+/obj/item/computer_hardware/battery/Initialize(mapload, battery_type)
+ . = ..()
if(battery_type)
battery = new battery_type(src)
- ..()
/obj/item/computer_hardware/battery/Destroy()
- . = ..()
- QDEL_NULL(battery)
+ if(battery)
+ QDEL_NULL(battery)
+ return ..()
-/obj/item/computer_hardware/battery/handle_atom_del(atom/A)
+///What happens when the battery is removed (or deleted) from the module, through try_eject() or not.
+/obj/item/computer_hardware/battery/Exited(atom/A, atom/newloc)
if(A == battery)
- try_eject(forced = TRUE)
- . = ..()
+ battery = null
+ if(holder?.enabled && !holder.use_power())
+ holder.shutdown_computer()
+ return ..()
/obj/item/computer_hardware/battery/try_insert(obj/item/I, mob/living/user = null)
if(!holder)
@@ -44,31 +48,20 @@
return TRUE
-
-/obj/item/computer_hardware/battery/try_eject(mob/living/user = null, forced = FALSE)
+/obj/item/computer_hardware/battery/try_eject(mob/living/user, forced = FALSE)
if(!battery)
to_chat(user, span_warning("There is no power cell connected to \the [src]."))
return FALSE
else
if(user)
user.put_in_hands(battery)
+ to_chat(user, span_notice("You detach \the [battery] from \the [src]."))
else
battery.forceMove(drop_location())
- to_chat(user, span_notice("You detach \the [battery] from \the [src]."))
- battery = null
-
- if(holder)
- if(holder.enabled && !holder.use_power())
- holder.shutdown_computer()
-
+
return TRUE
-
-
-
-
-
/obj/item/stock_parts/cell/computer
name = "standard battery"
desc = "A standard power cell, commonly seen in high-end portable microcomputers or low-end laptops."
diff --git a/code/modules/modular_computers/hardware/card_slot.dm b/code/modules/modular_computers/hardware/card_slot.dm
index fcef177fed03..f0d10f749525 100644
--- a/code/modules/modular_computers/hardware/card_slot.dm
+++ b/code/modules/modular_computers/hardware/card_slot.dm
@@ -6,16 +6,30 @@
w_class = WEIGHT_CLASS_TINY
device_type = MC_CARD
- var/obj/item/card/id/stored_card = null
+ var/obj/item/card/id/stored_card
-/obj/item/computer_hardware/card_slot/handle_atom_del(atom/A)
+///What happens when the ID card is removed (or deleted) from the module, through try_eject() or not.
+/obj/item/computer_hardware/card_slot/Exited(atom/A, atom/newloc)
if(A == stored_card)
- try_eject(null, TRUE)
- holder.update_label()
- . = ..()
+ stored_card = null
+ if(holder)
+ if(holder.active_program)
+ holder.active_program.event_idremoved(0)
+ for(var/p in holder.idle_threads)
+ var/datum/computer_file/program/computer_program = p
+ computer_program.event_idremoved(1)
+
+ holder.update_slot_icon()
+
+ if(ishuman(holder.loc))
+ var/mob/living/carbon/human/human_wearer = holder.loc
+ if(human_wearer.wear_id == holder)
+ human_wearer.sec_hud_set_ID()
+ return ..()
/obj/item/computer_hardware/card_slot/Destroy()
- try_eject()
+ if(stored_card) //If you didn't expect this behavior for some dumb reason, do something different instead of directly destroying the slot
+ QDEL_NULL(stored_card)
return ..()
/obj/item/computer_hardware/card_slot/GetAccess()
@@ -76,19 +90,7 @@
user.put_in_hands(stored_card)
else
stored_card.forceMove(drop_location())
- stored_card = null
- holder.update_label()
-
- if(holder)
- if(holder.active_program)
- holder.active_program.event_idremoved(0)
- for(var/p in holder.idle_threads)
- var/datum/computer_file/program/computer_program = p
- computer_program.event_idremoved(1)
- if(ishuman(user))
- var/mob/living/carbon/human/human_user = user
- human_user.sec_hud_set_ID()
to_chat(user, "You remove the card from \the [src]. ")
playsound(src, 'sound/machines/terminal_insert_disc.ogg', 50, FALSE)
return TRUE
diff --git a/code/modules/modular_computers/hardware/hard_drive.dm b/code/modules/modular_computers/hardware/hard_drive.dm
index b5a438785d19..7646bf7e6c82 100644
--- a/code/modules/modular_computers/hardware/hard_drive.dm
+++ b/code/modules/modular_computers/hardware/hard_drive.dm
@@ -10,8 +10,8 @@
var/used_capacity = 0
var/list/stored_files = list() // List of stored files on this drive. DO NOT MODIFY DIRECTLY!
-/obj/item/computer_hardware/hard_drive/on_remove(obj/item/modular_computer/MC, mob/user)
- MC.shutdown_computer()
+/obj/item/computer_hardware/hard_drive/on_remove(obj/item/modular_computer/remove_from, mob/user)
+ remove_from.shutdown_computer()
/obj/item/computer_hardware/hard_drive/proc/install_default_programs()
store_file(new/datum/computer_file/program/computerconfig(src)) // Computer configuration utility, allows hardware control and displays more info than status bar
diff --git a/code/modules/modular_computers/hardware/portable_disk.dm b/code/modules/modular_computers/hardware/portable_disk.dm
index 5c0eb6706dd7..61220b49365d 100644
--- a/code/modules/modular_computers/hardware/portable_disk.dm
+++ b/code/modules/modular_computers/hardware/portable_disk.dm
@@ -12,7 +12,7 @@
. = ..()
. += span_notice("Insert this disk into a modular computer and open the File Manager program to interact with it.")
-/obj/item/computer_hardware/hard_drive/portable/on_remove(obj/item/modular_computer/MC, mob/user)
+/obj/item/computer_hardware/hard_drive/portable/on_remove(obj/item/modular_computer/remove_from, mob/user)
return //this is a floppy disk, let's not shut the computer down when it gets pulled out.
/obj/item/computer_hardware/hard_drive/portable/install_default_programs()
diff --git a/code/modules/modular_computers/hardware/recharger.dm b/code/modules/modular_computers/hardware/recharger.dm
index 37a86e5b5947..9a2fe5e81b16 100644
--- a/code/modules/modular_computers/hardware/recharger.dm
+++ b/code/modules/modular_computers/hardware/recharger.dm
@@ -52,8 +52,8 @@
icon_state = "charger_wire"
w_class = WEIGHT_CLASS_BULKY
-/obj/item/computer_hardware/recharger/wired/can_install(obj/item/modular_computer/M, mob/living/user = null)
- if(ismachinery(M.physical) && M.physical.anchored)
+/obj/item/computer_hardware/recharger/wired/can_install(obj/item/modular_computer/install_into, mob/living/user = null)
+ if(ismachinery(install_into.physical) && install_into.physical.anchored)
return ..()
to_chat(user, span_warning("\The [src] is incompatible with portable computers!"))
return FALSE
diff --git a/code/modules/ninja/ninja_event.dm b/code/modules/ninja/ninja_event.dm
index 88f67f2a54a2..6098693a310a 100644
--- a/code/modules/ninja/ninja_event.dm
+++ b/code/modules/ninja/ninja_event.dm
@@ -87,8 +87,9 @@ Contents:
/proc/create_space_ninja(spawn_loc)
var/mob/living/carbon/human/new_ninja = new(spawn_loc)
- var/datum/preferences/A = new()//Randomize appearance for the ninja.
- A.real_name = "[pick(GLOB.ninja_titles)] [pick(GLOB.ninja_names)]"
- A.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/projectiles/projectile/magic.dm b/code/modules/projectiles/projectile/magic.dm
index d1c2a5321c84..5e246cd7afb1 100644
--- a/code/modules/projectiles/projectile/magic.dm
+++ b/code/modules/projectiles/projectile/magic.dm
@@ -245,10 +245,8 @@
if(chooseable_races.len)
new_mob.set_species(pick(chooseable_races))
- var/datum/preferences/A = new() //Randomize appearance for the human
- A.copy_to(new_mob, icon_updates=0)
-
var/mob/living/carbon/human/H = new_mob
+ H.randomize_human_appearance(~(RANDOMIZE_SPECIES))
H.update_body()
H.update_hair()
H.update_body_parts()
diff --git a/code/modules/reagents/chemistry/machinery/chem_master.dm b/code/modules/reagents/chemistry/machinery/chem_master.dm
index 117b4ae50b2b..abf36adeb536 100644
--- a/code/modules/reagents/chemistry/machinery/chem_master.dm
+++ b/code/modules/reagents/chemistry/machinery/chem_master.dm
@@ -25,15 +25,6 @@
/obj/machinery/chem_master/Initialize()
create_reagents(100)
- //Calculate the span tags and ids fo all the available pill icons
- var/datum/asset/spritesheet/simple/assets = get_asset_datum(/datum/asset/spritesheet/simple/pills)
- pillStyles = list()
- for (var/x in 1 to PILL_STYLE_COUNT)
- var/list/SL = list()
- SL["id"] = x
- SL["className"] = assets.icon_class_name("pill[x]")
- pillStyles += list(SL)
-
. = ..()
/obj/machinery/chem_master/Destroy()
@@ -151,6 +142,16 @@
bottle = null
return ..()
+/obj/machinery/chem_master/proc/load_styles()
+ //Calculate the span tags and ids fo all the available pill icons
+ var/datum/asset/spritesheet/simple/assets = get_asset_datum(/datum/asset/spritesheet/simple/pills)
+ pillStyles = list()
+ for (var/x in 1 to PILL_STYLE_COUNT)
+ var/list/SL = list()
+ SL["id"] = x
+ SL["className"] = assets.icon_class_name("pill[x]")
+ pillStyles += list(SL)
+
/obj/machinery/chem_master/ui_assets(mob/user)
return list(
get_asset_datum(/datum/asset/spritesheet/simple/pills),
@@ -190,7 +191,9 @@
bufferContents.Add(list(list("name" = N.name, "id" = ckey(N.name), "volume" = N.volume))) // ^
data["bufferContents"] = bufferContents
- //Calculated at init time as it never changes
+ //Calculated once since it'll never change
+ if(!pillStyles)
+ load_styles()
data["pillStyles"] = pillStyles
return data
diff --git a/code/modules/research/techweb/_techweb_node.dm b/code/modules/research/techweb/_techweb_node.dm
index 7945eacf8843..5bf1cff4a57c 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(., research_costs)
diff --git a/code/modules/research/xenobiology/xenobiology.dm b/code/modules/research/xenobiology/xenobiology.dm
index fd3940052a51..0022f70a258b 100644
--- a/code/modules/research/xenobiology/xenobiology.dm
+++ b/code/modules/research/xenobiology/xenobiology.dm
@@ -733,6 +733,7 @@
SM.key = C.key
SM.sentience_act()
SM.mind.enslave_mind_to_creator(user)
+ SM.mind.add_antag_datum(/datum/antagonist/sentient_creature)
to_chat(SM, span_warning("All at once it makes sense: you know what you are and who you are! Self awareness is yours!"))
to_chat(SM, span_userdanger("You are grateful to be self aware and owe [user.real_name] a great debt. Serve [user.real_name], and assist [user.p_them()] in completing [user.p_their()] goals at any cost."))
if(SM.flags_1 & HOLOGRAM_1) //Check to see if it's a holodeck creature
diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm
index d7b697deb7d7..b73842544b4b 100644
--- a/code/modules/tgui/tgui.dm
+++ b/code/modules/tgui/tgui.dm
@@ -1,4 +1,4 @@
-/**
+/*!
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
@@ -54,7 +54,7 @@
*/
/datum/tgui/New(mob/user, datum/src_object, interface, title, ui_x, ui_y)
log_tgui(user,
- "new [interface] fancy [user.client.prefs.tgui_fancy]",
+ "new [interface] fancy [user?.client?.prefs.read_preference(/datum/preference/toggle/tgui_fancy)]",
src_object = src_object)
src.user = user
src.src_object = src_object
@@ -62,11 +62,16 @@
src.interface = interface
if(title)
src.title = title
- src.state = src_object.ui_state()
+ src.state = src_object.ui_state(user)
// Deprecated
if(ui_x && ui_y)
src.window_size = list(ui_x, ui_y)
+/datum/tgui/Destroy()
+ user = null
+ src_object = null
+ return ..()
+
/**
* public
*
@@ -89,8 +94,8 @@
window.acquire_lock(src)
if(!window.is_ready())
window.initialize(
- fancy = user.client.prefs.tgui_fancy,
- inline_assets = list(
+ fancy = user.client.prefs.read_preference(/datum/preference/toggle/tgui_fancy),
+ assets = list(
get_asset_datum(/datum/asset/simple/tgui),
))
else
@@ -224,8 +229,8 @@
"window" = list(
"key" = window_key,
"size" = window_size,
- "fancy" = user.client.prefs.tgui_fancy,
- "locked" = user.client.prefs.tgui_lock,
+ "fancy" = user.client.prefs.read_preference(/datum/preference/toggle/tgui_fancy),
+ "locked" = user.client.prefs.read_preference(/datum/preference/toggle/tgui_lock),
),
"client" = list(
"ckey" = user.client.ckey,
@@ -308,7 +313,7 @@
return FALSE
switch(type)
if("ready")
- // Send a full update when the user manually refreshes the UI
+ // Send a full update when the user manually refreshes the UI
if(initialized)
send_full_update()
initialized = TRUE
diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm
index cfc788768c97..18e87468325d 100644
--- a/code/modules/tgui/tgui_window.dm
+++ b/code/modules/tgui/tgui_window.dm
@@ -1,4 +1,4 @@
-/**
+/*!
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
@@ -18,8 +18,11 @@
var/message_queue
var/sent_assets = list()
// Vars passed to initialize proc (and saved for later)
- var/inline_assets
- var/fancy
+ var/initial_fancy
+ var/initial_assets
+ var/initial_inline_html
+ var/initial_inline_js
+ var/initial_inline_css
/**
* public
@@ -44,21 +47,26 @@
* state. You can begin sending messages right after initializing. Messages
* will be put into the queue until the window finishes loading.
*
- * optional inline_assets list List of assets to inline into the html.
+ * optional assets list List of assets to inline into the html.
* optional inline_html string Custom HTML to inject.
* optional fancy bool If TRUE, will hide the window titlebar.
*/
/datum/tgui_window/proc/initialize(
- inline_assets = list(),
+ fancy = FALSE,
+ assets = list(),
inline_html = "",
- fancy = FALSE)
+ inline_js = "",
+ inline_css = "")
log_tgui(client,
context = "[id]/initialize",
window = src)
if(!client)
return
- src.inline_assets = inline_assets
- src.fancy = fancy
+ src.initial_fancy = fancy
+ src.initial_assets = assets
+ src.initial_inline_html = inline_html
+ src.initial_inline_js = inline_js
+ src.initial_inline_css = inline_css
status = TGUI_WINDOW_LOADING
fatally_errored = FALSE
// Build window options
@@ -71,9 +79,9 @@
// Generate page html
var/html = SStgui.basehtml
html = replacetextEx(html, "\[tgui:windowId]", id)
- // Inject inline assets
+ // Inject assets
var/inline_assets_str = ""
- for(var/datum/asset/asset in inline_assets)
+ for(var/datum/asset/asset in assets)
var/mappings = asset.get_url_mappings()
for(var/name in mappings)
var/url = mappings[name]
@@ -86,8 +94,17 @@
if(length(inline_assets_str))
inline_assets_str = "\n"
html = replacetextEx(html, "\n", inline_assets_str)
- // Inject custom HTML
- html = replacetextEx(html, "\n", inline_html)
+ // Inject inline HTML
+ if (inline_html)
+ html = replacetextEx(html, "", inline_html)
+ // Inject inline JS
+ if (inline_js)
+ inline_js = ""
+ html = replacetextEx(html, "", inline_js)
+ // Inject inline CSS
+ if (inline_css)
+ inline_css = ""
+ html = replacetextEx(html, "", inline_css)
// Open the window
client << browse(html, "window=[id];[options]")
// Detect whether the control is a browser
@@ -258,7 +275,7 @@
if(istype(asset, /datum/asset/spritesheet))
var/datum/asset/spritesheet/spritesheet = asset
send_message("asset/stylesheet", spritesheet.css_filename())
- send_message("asset/mappings", asset.get_url_mappings())
+ send_raw_message(asset.get_serialized_url_mappings())
/**
* private
@@ -317,7 +334,15 @@
client << link(href_list["url"])
if("cacheReloaded")
// Reinitialize
- initialize(inline_assets = inline_assets, fancy = fancy)
+ initialize(
+ fancy = initial_fancy,
+ assets = initial_assets,
+ inline_html = initial_inline_html,
+ inline_js = initial_inline_js,
+ inline_css = initial_inline_css)
// Resend the assets
for(var/asset in sent_assets)
send_asset(asset)
+
+/datum/tgui_window/vv_edit_var(var_name, var_value)
+ return var_name != NAMEOF(src, id) && ..()
diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm
index a75ce59f0173..b98fbfbbdf82 100644
--- a/code/modules/tgui_panel/tgui_panel.dm
+++ b/code/modules/tgui_panel/tgui_panel.dm
@@ -1,4 +1,4 @@
-/**
+/*!
* Copyright (c) 2020 Aleksej Komarov
* SPDX-License-Identifier: MIT
*/
@@ -12,7 +12,6 @@
var/datum/tgui_window/window
var/broken = FALSE
var/initialized_at
- var/retries = 0
/datum/tgui_panel/New(client/client)
src.client = client
@@ -40,53 +39,27 @@
/datum/tgui_panel/proc/initialize(force = FALSE)
set waitfor = FALSE
// Minimal sleep to defer initialization to after client constructor
- sleep(0.1 SECONDS)
+ sleep(1)
initialized_at = world.time
// Perform a clean initialization
- window.initialize(inline_assets = list(
+ window.initialize(assets = list(
get_asset_datum(/datum/asset/simple/tgui_panel),
))
window.send_asset(get_asset_datum(/datum/asset/simple/namespaced/fontawesome))
window.send_asset(get_asset_datum(/datum/asset/simple/namespaced/tgfont))
window.send_asset(get_asset_datum(/datum/asset/spritesheet/chat))
- // Preload assets for /datum/tgui
- var/datum/asset/asset_tgui = get_asset_datum(/datum/asset/simple/tgui)
- var/flush_queue = asset_tgui.send(src.client)
- if(flush_queue)
- src.client.browse_queue_flush()
// Other setup
request_telemetry()
- if(!telemetry_connections && retries < 6)
- addtimer(CALLBACK(src, .proc/check_telemetry), 2 SECONDS)
- addtimer(CALLBACK(src, .proc/on_initialize_timed_out), 2 SECONDS)
-
-/datum/tgui_panel/proc/check_telemetry()
- if(!telemetry_connections) /// Somethings fucked lets try again.
- if(retries > 2)
- if(client && istype(client))
- winset(client, null, "command=.reconnect") /// Kitchen Sink
- qdel(client)
- if(retries > 3)
- qdel(client)
- if(retries > 5)
- return // I give up
- if(retries < 6)
- retries++
- src << browse(file('html/statbrowser.html'), "window=statbrowser") /// Reloads the statpanel as well
- initialize() /// Lets just start again
- var/mob/dead/new_player/M = client?.mob
- if(istype(M))
- M.Login()
+ addtimer(CALLBACK(src, .proc/on_initialize_timed_out), 5 SECONDS)
/**
* private
*
* Called when initialization has timed out.
*/
-
/datum/tgui_panel/proc/on_initialize_timed_out()
// Currently does nothing but sending a message to old chat.
- SEND_TEXT(client, span_userdanger("Failed to load fancy chat, click HERE to attempt to reload it."))
+ SEND_TEXT(client, "Failed to load fancy chat, click HERE to attempt to reload it. ")
/**
* private
@@ -124,6 +97,3 @@
*/
/datum/tgui_panel/proc/send_roundrestart()
window.send_message("roundrestart")
-
-/datum/tgui_panel/proc/send_connected()
- window.send_message("reconnected")
diff --git a/code/modules/tooltip/tooltip.dm b/code/modules/tooltip/tooltip.dm
index 31e570cd9431..e610479084af 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_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/config/admins.txt b/config/admins.txt
index f15744c4b491..1b2010bc989d 100644
--- a/config/admins.txt
+++ b/config/admins.txt
@@ -16,3 +16,4 @@ baiomu = Council Member
ajhchenry = Council Member
jamied12 = Head Developer
+theos = Head Developer
diff --git a/config/config.txt b/config/config.txt
index 56b826017c56..c7d1b7fb6dee 100644
--- a/config/config.txt
+++ b/config/config.txt
@@ -454,3 +454,10 @@ MAX_SHUTTLE_SIZE 300
## Enable/disable roundstart station traits
#STATION_TRAITS
+
+## Assets can opt-in to caching their results into `tmp`.
+## This is important, as preferences assets take upwards of 30 seconds (without sleeps) to collect.
+## The cache is assumed to be cleared by TGS recompiling, which deletes `tmp`.
+## This should be disabled (through `CACHE_ASSETS 0`) on development,
+## but enabled on production (the default).
+CACHE_ASSETS 1
diff --git a/html/changelogs/AutoChangelog-pr-17368.yml b/html/changelogs/AutoChangelog-pr-17368.yml
new file mode 100644
index 000000000000..37b3d756b484
--- /dev/null
+++ b/html/changelogs/AutoChangelog-pr-17368.yml
@@ -0,0 +1,6 @@
+author: "Mothblocks & a lot of people from /tg/"
+delete-after: true
+changes:
+ - rscadd: "The preferences menu has been completely rewritten in tgui."
+ - tweak: "The \"Preferences\" tab has been removed, the menu verbs have been moved to the OOC tab"
+ - tweak: "The \"Stop Sounds\" verb has been moved to OOC."
diff --git a/html/font-awesome/webfonts/fa-regular-400.eot b/html/font-awesome/webfonts/fa-regular-400.eot
deleted file mode 100644
index d62be2fad885..000000000000
Binary files a/html/font-awesome/webfonts/fa-regular-400.eot and /dev/null differ
diff --git a/html/font-awesome/webfonts/fa-regular-400.woff b/html/font-awesome/webfonts/fa-regular-400.woff
deleted file mode 100644
index 43b1a9ae49db..000000000000
Binary files a/html/font-awesome/webfonts/fa-regular-400.woff and /dev/null differ
diff --git a/html/font-awesome/webfonts/fa-solid-900.eot b/html/font-awesome/webfonts/fa-solid-900.eot
deleted file mode 100644
index c77baa8d46ab..000000000000
Binary files a/html/font-awesome/webfonts/fa-solid-900.eot and /dev/null differ
diff --git a/html/font-awesome/webfonts/fa-solid-900.woff b/html/font-awesome/webfonts/fa-solid-900.woff
deleted file mode 100644
index 77c1786227f5..000000000000
Binary files a/html/font-awesome/webfonts/fa-solid-900.woff and /dev/null differ
diff --git a/icons/Testing/greyscale_error.dmi b/icons/Testing/greyscale_error.dmi
new file mode 100644
index 000000000000..6c781a70ad19
Binary files /dev/null and b/icons/Testing/greyscale_error.dmi differ
diff --git a/icons/UI_Icons/antags/obsessed.dmi b/icons/UI_Icons/antags/obsessed.dmi
new file mode 100644
index 000000000000..219a6e594132
Binary files /dev/null and b/icons/UI_Icons/antags/obsessed.dmi differ
diff --git a/icons/blanks/32x32.dmi b/icons/blanks/32x32.dmi
new file mode 100644
index 000000000000..6c4f2b33e0fe
Binary files /dev/null and b/icons/blanks/32x32.dmi differ
diff --git a/icons/blanks/96x96.dmi b/icons/blanks/96x96.dmi
new file mode 100644
index 000000000000..d79e60c111ab
Binary files /dev/null and b/icons/blanks/96x96.dmi differ
diff --git a/icons/blanks/blank_title.png b/icons/blanks/blank_title.png
new file mode 100644
index 000000000000..387a21f03a01
Binary files /dev/null and b/icons/blanks/blank_title.png differ
diff --git a/icons/effects/64x64.dmi b/icons/effects/64x64.dmi
index 6b0cf1efc98e..7f18c6166a3e 100644
Binary files a/icons/effects/64x64.dmi and b/icons/effects/64x64.dmi differ
diff --git a/icons/mob/animal.dmi b/icons/mob/animal.dmi
index bcea2fb4e5a0..4b5ec0990bf2 100644
Binary files a/icons/mob/animal.dmi and b/icons/mob/animal.dmi differ
diff --git a/icons/mob/hud.dmi b/icons/mob/hud.dmi
index b56dfc2f3448..6f626fe09f85 100644
Binary files a/icons/mob/hud.dmi and b/icons/mob/hud.dmi differ
diff --git a/icons/mob/human.dmi b/icons/mob/human.dmi
index e0fe6b4d9889..63284a0012f2 100644
Binary files a/icons/mob/human.dmi and b/icons/mob/human.dmi differ
diff --git a/interface/menu.dm b/interface/menu.dm
index c670a58a5203..ec9719e82365 100644
--- a/interface/menu.dm
+++ b/interface/menu.dm
@@ -8,7 +8,6 @@
GLOBAL_LIST_EMPTY(menulist)
/datum/verbs/menu
- var/checkbox = CHECKBOX_NONE //checkbox type.
var/default //default checked type.
//Set to true to append our children to our parent,
//Rather then add us as a node (used for having more then one checkgroups in the same menu)
@@ -17,48 +16,4 @@ GLOBAL_LIST_EMPTY(menulist)
return GLOB.menulist
/datum/verbs/menu/HandleVerb(list/entry, verbpath, client/C)
- var/datum/verbs/menu/verb_true_parent = GLOB.menulist[verblist[verbpath]]
- var/true_checkbox = verb_true_parent.checkbox
- if (true_checkbox != CHECKBOX_NONE)
- var/checkedverb = verb_true_parent.Get_checked(C)
- if (true_checkbox == CHECKBOX_GROUP)
- if (verbpath == checkedverb)
- entry["is-checked"] = TRUE
- else
- entry["is-checked"] = FALSE
- else if (true_checkbox == CHECKBOX_TOGGLE)
- entry["is-checked"] = checkedverb
-
- entry["command"] = ".updatemenuchecked \"[verb_true_parent.type]\" \"[verbpath]\"\n[entry["command"]]"
- entry["can-check"] = TRUE
- entry["group"] = "[verb_true_parent.type]"
return list2params(entry)
-
-/datum/verbs/menu/proc/Get_checked(client/C)
- return C.prefs.menuoptions[type] || default || FALSE
-
-/datum/verbs/menu/proc/Load_checked(client/C) //Loads the checked menu item into a new client. Used by icon menus to invoke the checked item.
- return
-
-/datum/verbs/menu/proc/Set_checked(client/C, verbpath)
- if (checkbox == CHECKBOX_GROUP)
- C.prefs.menuoptions[type] = verbpath
- C.prefs.save_preferences()
- else if (checkbox == CHECKBOX_TOGGLE)
- var/checked = Get_checked(C)
- C.prefs.menuoptions[type] = !checked
- C.prefs.save_preferences()
- winset(C, "[verbpath]", "is-checked = [!checked]")
-
-/client/verb/updatemenuchecked(menutype as text, verbpath as text)
- set name = ".updatemenuchecked"
- menutype = text2path(menutype)
- verbpath = text2path(verbpath)
- if (!menutype || !verbpath)
- return
- var/datum/verbs/menu/M = GLOB.menulist[menutype]
- if (!M)
- return
- if (!(verbpath in typesof("[menutype]/verb")))
- return
- M.Set_checked(src, verbpath)
\ No newline at end of file
diff --git a/tgui/.eslintrc.yml b/tgui/.eslintrc.yml
index 97529e27e7fb..3cc709385bae 100644
--- a/tgui/.eslintrc.yml
+++ b/tgui/.eslintrc.yml
@@ -1,4 +1,5 @@
root: true
+##extends: prettier
parser: '@typescript-eslint/parser'
parserOptions:
ecmaVersion: 2020
@@ -12,6 +13,7 @@ env:
plugins:
- radar
- react
+# - unused-imports
settings:
react:
version: '16.10'
@@ -641,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
@@ -761,3 +763,4 @@ rules:
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/.gitignore b/tgui/.gitignore
index 4d0dd666d88e..bb34d61b3ce8 100644
--- a/tgui/.gitignore
+++ b/tgui/.gitignore
@@ -16,9 +16,8 @@ package-lock.json
/public/.tmp/**/*
/public/**/*
!/public/*.html
-!/public/tgui-polyfill.min.js
+!/public/tgui-polyfill.bundle.js
/coverage
## Previously ignored locations that are kept to avoid confusing git
## while transitioning to a new project structure.
-/packages/tgui/public/**
diff --git a/tgui/.yarnrc.yml b/tgui/.yarnrc.yml
index 65f95873521c..b6387e8e46e8 100644
--- a/tgui/.yarnrc.yml
+++ b/tgui/.yarnrc.yml
@@ -1,3 +1,5 @@
+enableScripts: false
+
logFilters:
- code: YN0004
level: discard
@@ -8,6 +10,8 @@ plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
+pnpEnableEsmLoader: false
+
preferAggregateCacheInfo: true
preferInteractive: true
diff --git a/tgui/global.d.ts b/tgui/global.d.ts
index 7f2cc8f7282c..348cb9c5165c 100644
--- a/tgui/global.d.ts
+++ b/tgui/global.d.ts
@@ -4,155 +4,179 @@
* @license MIT
*/
-declare global {
- // Webpack asset modules.
- // Should match extensions used in webpack config.
- declare module '*.png' {
- const content: string;
- export default content;
- }
-
- declare module '*.jpg' {
- const content: string;
- export default content;
- }
-
- declare module '*.svg' {
- const content: string;
- export default content;
- }
-
- type ByondType = {
- /**
- * True if javascript is running in BYOND.
- */
- IS_BYOND: boolean;
-
- /**
- * True if browser is IE8 or lower.
- */
- IS_LTE_IE8: boolean;
-
- /**
- * True if browser is IE9 or lower.
- */
- IS_LTE_IE9: boolean;
-
- /**
- * True if browser is IE10 or lower.
- */
- IS_LTE_IE10: boolean;
-
- /**
- * True if browser is IE11 or lower.
- */
- IS_LTE_IE11: boolean;
-
- /**
- * Makes a BYOND call.
- *
- * If path is empty, this will trigger a Topic call.
- * You can reference a specific object by setting the "src" parameter.
- *
- * See: https://secure.byond.com/docs/ref/skinparams.html
- */
- call(path: string, params: object): void;
-
- /**
- * Makes an asynchronous BYOND call. Returns a promise.
- */
- callAsync(path: string, params: object): Promise;
-
- /**
- * Makes a Topic call.
- *
- * You can reference a specific object by setting the "src" parameter.
- */
- topic(params: object): void;
-
- /**
- * Runs a command or a verb.
- */
- command(command: string): void;
-
- /**
- * Retrieves all properties of the BYOND skin element.
- *
- * Returns a promise with a key-value object containing all properties.
- */
- winget(id: string): Promise;
-
- /**
- * Retrieves all properties of the BYOND skin element.
- *
- * Returns a promise with a key-value object containing all properties.
- */
- winget(id: string, propName: '*'): Promise;
-
- /**
- * Retrieves an exactly one property of the BYOND skin element,
- * as defined in `propName`.
- *
- * Returns a promise with the value of that property.
- */
- winget(id: string, propName: string): Promise;
-
- /**
- * Retrieves multiple properties of the BYOND skin element,
- * as defined in the `propNames` array.
- *
- * Returns a promise with a key-value object containing listed properties.
- */
- winget(id: string, propNames: string[]): Promise;
-
- /**
- * Assigns properties to BYOND skin elements.
- */
- winset(props: object): void;
-
- /**
- * Assigns properties to the BYOND skin element.
- */
- winset(id: string, props: object): void;
-
- /**
- * Sets a property on the BYOND skin element to a certain value.
- */
- winset(id: string, propName: string, propValue: any): void;
-
- /**
- * Parses BYOND JSON.
- *
- * Uses a special encoding to preverse Infinity and NaN.
- */
- parseJson(text: string): any;
-
- /**
- * Loads a stylesheet into the document.
- */
- loadCss(url: string): void;
-
- /**
- * Loads a script into the document.
- */
- loadJs(url: string): void;
- };
-
- /**
- * Object that provides access to Byond Skin API and is available in
- * any tgui application.
- */
- const Byond: ByondType;
-
- interface Window {
- /**
- * ID of the Byond window this script is running on.
- * Should be used as a parameter to winget/winset.
- */
- __windowId__: string;
- Byond: ByondType;
- }
+// Webpack asset modules.
+// Should match extensions used in webpack config.
+declare module '*.png' {
+ const content: string;
+ export default content;
+}
+
+declare module '*.jpg' {
+ const content: string;
+ export default content;
+}
+declare module '*.svg' {
+ const content: string;
+ export default content;
}
-export {};
+type TguiMessage = {
+ type: string;
+ payload?: any;
+ [key: string]: any;
+};
+
+type ByondType = {
+ /**
+ * ID of the Byond window this script is running on.
+ * Can be used as a parameter to winget/winset.
+ */
+ windowId: string;
+
+ /**
+ * True if javascript is running in BYOND.
+ */
+ IS_BYOND: boolean;
+
+ /**
+ * Version of Trident engine of Internet Explorer. Null if N/A.
+ */
+ TRIDENT: number | null;
+
+ /**
+ * True if browser is IE8 or lower.
+ */
+ IS_LTE_IE8: boolean;
+
+ /**
+ * True if browser is IE9 or lower.
+ */
+ IS_LTE_IE9: boolean;
+
+ /**
+ * True if browser is IE10 or lower.
+ */
+ IS_LTE_IE10: boolean;
+
+ /**
+ * True if browser is IE11 or lower.
+ */
+ IS_LTE_IE11: boolean;
+
+ /**
+ * Makes a BYOND call.
+ *
+ * If path is empty, this will trigger a Topic call.
+ * You can reference a specific object by setting the "src" parameter.
+ *
+ * See: https://secure.byond.com/docs/ref/skinparams.html
+ */
+ call(path: string, params: object): void;
+
+ /**
+ * Makes an asynchronous BYOND call. Returns a promise.
+ */
+ callAsync(path: string, params: object): Promise;
+
+ /**
+ * Makes a Topic call.
+ *
+ * You can reference a specific object by setting the "src" parameter.
+ */
+ topic(params: object): void;
+
+ /**
+ * Runs a command or a verb.
+ */
+ command(command: string): void;
+
+ /**
+ * Retrieves all properties of the BYOND skin element.
+ *
+ * Returns a promise with a key-value object containing all properties.
+ */
+ winget(id: string | null): Promise;
+
+ /**
+ * Retrieves all properties of the BYOND skin element.
+ *
+ * Returns a promise with a key-value object containing all properties.
+ */
+ winget(id: string | null, propName: '*'): Promise;
+
+ /**
+ * Retrieves an exactly one property of the BYOND skin element,
+ * as defined in `propName`.
+ *
+ * Returns a promise with the value of that property.
+ */
+ winget(id: string | null, propName: string): Promise;
+
+ /**
+ * Retrieves multiple properties of the BYOND skin element,
+ * as defined in the `propNames` array.
+ *
+ * Returns a promise with a key-value object containing listed properties.
+ */
+ winget(id: string | null, propNames: string[]): Promise;
+
+ /**
+ * Assigns properties to BYOND skin elements in bulk.
+ */
+ winset(props: object): void;
+
+ /**
+ * Assigns properties to the BYOND skin element.
+ */
+ winset(id: string | null, props: object): void;
+
+ /**
+ * Sets a property on the BYOND skin element to a certain value.
+ */
+ winset(id: string | null, propName: string, propValue: any): void;
+
+ /**
+ * Parses BYOND JSON.
+ *
+ * Uses a special encoding to preserve `Infinity` and `NaN`.
+ */
+ parseJson(text: string): any;
+
+ /**
+ * Sends a message to `/datum/tgui_window` which hosts this window instance.
+ */
+ sendMessage(type: string, payload?: any): void;
+ sendMessage(message: TguiMessage): void;
+
+ /**
+ * Subscribe to incoming messages that were sent from `/datum/tgui_window`.
+ */
+ subscribe(listener: (type: string, payload: any) => void): void;
+
+ /**
+ * Subscribe to incoming messages *of some specific type*
+ * that were sent from `/datum/tgui_window`.
+ */
+ subscribeTo(type: string, listener: (payload: any) => void): void;
+
+ /**
+ * Loads a stylesheet into the document.
+ */
+ loadCss(url: string): void;
+
+ /**
+ * Loads a script into the document.
+ */
+ loadJs(url: string): void;
+};
+
+/**
+ * Object that provides access to Byond Skin API and is available in
+ * any tgui application.
+ */
+const Byond: ByondType;
+
+interface Window {
+ Byond: ByondType;
+}
diff --git a/tgui/package.json b/tgui/package.json
index 0f65150c4381..3bfdc3a412ad 100644
--- a/tgui/package.json
+++ b/tgui/package.json
@@ -2,21 +2,22 @@
"private": true,
"name": "tgui-workspace",
"version": "4.3.0",
+ "packageManager": "yarn@3.1.1",
"workspaces": [
"packages/*"
],
"scripts": {
"tgui:analyze": "webpack --analyze",
- "tgui:bench": "webpack --env TGUI_BENCH=1 && node packages/tgui-bench/index.js",
"tgui:build": "BROWSERSLIST_IGNORE_OLD_DATA=true webpack",
"tgui:dev": "node --experimental-modules packages/tgui-dev-server/index.js",
"tgui:lint": "eslint packages --ext .js,.cjs,.ts,.tsx",
"tgui:prettier": "",
"tgui:sonar": "eslint packages --ext .js,.cjs,.ts,.tsx -c .eslintrc-sonar.yml",
+ "tgui:tsc": "tsc",
"tgui:test": "jest --watch",
"tgui:test-simple": "CI=true jest --color",
"tgui:test-ci": "CI=true jest --color --collect-coverage",
- "tgui:tsc": "tsc"
+ "tgui:bench": "webpack --env TGUI_BENCH=1 && node packages/tgui-bench/index.js"
},
"dependencies": {
"@babel/core": "^7.15.0",
@@ -28,6 +29,8 @@
"@types/jest": "^27.0.1",
"@types/jsdom": "^16.2.13",
"@types/node": "^14.17.9",
+ "@types/webpack": "^5.28.0",
+ "@types/webpack-env": "^1.16.2",
"@typescript-eslint/parser": "^4.29.1",
"babel-jest": "^27.0.6",
"babel-loader": "^8.2.2",
@@ -38,6 +41,7 @@
"eslint": "^7.32.0",
"eslint-plugin-radar": "^0.2.1",
"eslint-plugin-react": "^7.24.0",
+ "eslint-plugin-unused-imports": "^1.1.4",
"file-loader": "^6.2.0",
"inferno": "^7.4.8",
"jest": "^27.0.6",
@@ -53,6 +57,5 @@
"webpack": "^5.50.0",
"webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^4.7.2"
- },
- "packageManager": "yarn@3.0.1"
+ }
}
diff --git a/tgui/packages/common/collections.js b/tgui/packages/common/collections.js
deleted file mode 100644
index 7b4540e730ab..000000000000
--- a/tgui/packages/common/collections.js
+++ /dev/null
@@ -1,271 +0,0 @@
-/**
- * @file
- * @copyright 2020 Aleksej Komarov
- * @license MIT
- */
-
-/**
- * Converts a given collection to an array.
- *
- * - Arrays are returned unmodified;
- * - If object was provided, keys will be discarded;
- * - Everything else will result in an empty array.
- *
- * @returns {any[]}
- */
-export const toArray = collection => {
- if (Array.isArray(collection)) {
- return collection;
- }
- if (typeof collection === 'object') {
- const hasOwnProperty = Object.prototype.hasOwnProperty;
- const result = [];
- for (let i in collection) {
- if (hasOwnProperty.call(collection, i)) {
- result.push(collection[i]);
- }
- }
- return result;
- }
- return [];
-};
-
-/**
- * Converts a given object to an array, and appends a key to every
- * object inside of that array.
- *
- * Example input (object):
- * ```
- * {
- * 'Foo': { info: 'Hello world!' },
- * 'Bar': { info: 'Hello world!' },
- * }
- * ```
- *
- * Example output (array):
- * ```
- * [
- * { key: 'Foo', info: 'Hello world!' },
- * { key: 'Bar', info: 'Hello world!' },
- * ]
- * ```
- *
- * @template T
- * @param {{ [key: string]: T }} obj Object, or in DM terms, an assoc array
- * @param {string} keyProp Property, to which key will be assigned
- * @returns {T[]} Array of keyed objects
- */
-export const toKeyedArray = (obj, keyProp = 'key') => {
- return map((item, key) => ({
- [keyProp]: key,
- ...item,
- }))(obj);
-};
-
-/**
- * Iterates over elements of collection, returning an array of all elements
- * iteratee returns truthy for. The predicate is invoked with three
- * arguments: (value, index|key, collection).
- *
- * If collection is 'null' or 'undefined', it will be returned "as is"
- * without emitting any errors (which can be useful in some cases).
- *
- * @returns {any[]}
- */
-export const filter = iterateeFn => collection => {
- if (collection === null || collection === undefined) {
- return collection;
- }
- if (Array.isArray(collection)) {
- const result = [];
- for (let i = 0; i < collection.length; i++) {
- const item = collection[i];
- if (iterateeFn(item, i, collection)) {
- result.push(item);
- }
- }
- return result;
- }
- throw new Error(`filter() can't iterate on type ${typeof collection}`);
-};
-
-/**
- * Creates an array of values by running each element in collection
- * thru an iteratee function. The iteratee is invoked with three
- * arguments: (value, index|key, collection).
- *
- * If collection is 'null' or 'undefined', it will be returned "as is"
- * without emitting any errors (which can be useful in some cases).
- *
- * @returns {any[]}
- */
-export const map = iterateeFn => collection => {
- if (collection === null || collection === undefined) {
- return collection;
- }
- if (Array.isArray(collection)) {
- const result = [];
- for (let i = 0; i < collection.length; i++) {
- result.push(iterateeFn(collection[i], i, collection));
- }
- return result;
- }
- if (typeof collection === 'object') {
- const hasOwnProperty = Object.prototype.hasOwnProperty;
- const result = [];
- for (let i in collection) {
- if (hasOwnProperty.call(collection, i)) {
- result.push(iterateeFn(collection[i], i, collection));
- }
- }
- return result;
- }
- throw new Error(`map() can't iterate on type ${typeof collection}`);
-};
-
-const COMPARATOR = (objA, objB) => {
- const criteriaA = objA.criteria;
- const criteriaB = objB.criteria;
- const length = criteriaA.length;
- for (let i = 0; i < length; i++) {
- const a = criteriaA[i];
- const b = criteriaB[i];
- if (a < b) {
- return -1;
- }
- if (a > b) {
- return 1;
- }
- }
- return 0;
-};
-
-/**
- * Creates an array of elements, sorted in ascending order by the results
- * of running each element in a collection thru each iteratee.
- *
- * Iteratees are called with one argument (value).
- *
- * @returns {any[]}
- */
-export const sortBy = (...iterateeFns) => array => {
- if (!Array.isArray(array)) {
- return array;
- }
- let length = array.length;
- // Iterate over the array to collect criteria to sort it by
- let mappedArray = [];
- for (let i = 0; i < length; i++) {
- const value = array[i];
- mappedArray.push({
- criteria: iterateeFns.map(fn => fn(value)),
- value,
- });
- }
- // Sort criteria using the base comparator
- mappedArray.sort(COMPARATOR);
- // Unwrap values
- while (length--) {
- mappedArray[length] = mappedArray[length].value;
- }
- return mappedArray;
-};
-
-/**
- * A fast implementation of reduce.
- */
-export const reduce = (reducerFn, initialValue) => array => {
- const length = array.length;
- let i;
- let result;
- if (initialValue === undefined) {
- i = 1;
- result = array[0];
- }
- else {
- i = 0;
- result = initialValue;
- }
- for (; i < length; i++) {
- result = reducerFn(result, array[i], i, array);
- }
- return result;
-};
-
-/**
- * Creates a duplicate-free version of an array, using SameValueZero for
- * equality comparisons, in which only the first occurrence of each element
- * is kept. The order of result values is determined by the order they occur
- * in the array.
- *
- * It accepts iteratee which is invoked for each element in array to generate
- * the criterion by which uniqueness is computed. The order of result values
- * is determined by the order they occur in the array. The iteratee is
- * invoked with one argument: value.
- */
-export const uniqBy = iterateeFn => array => {
- const { length } = array;
- const result = [];
- const seen = iterateeFn ? [] : result;
- let index = -1;
- outer:
- while (++index < length) {
- let value = array[index];
- const computed = iterateeFn ? iterateeFn(value) : value;
- value = value !== 0 ? value : 0;
- if (computed === computed) {
- let seenIndex = seen.length;
- while (seenIndex--) {
- if (seen[seenIndex] === computed) {
- continue outer;
- }
- }
- if (iterateeFn) {
- seen.push(computed);
- }
- result.push(value);
- }
- else if (!seen.includes(computed)) {
- if (seen !== result) {
- seen.push(computed);
- }
- result.push(value);
- }
- }
- return result;
-};
-
-/**
- * Creates an array of grouped elements, the first of which contains
- * the first elements of the given arrays, the second of which contains
- * the second elements of the given arrays, and so on.
- *
- * @returns {any[]}
- */
-export const zip = (...arrays) => {
- if (arrays.length === 0) {
- return;
- }
- const numArrays = arrays.length;
- const numValues = arrays[0].length;
- const result = [];
- for (let valueIndex = 0; valueIndex < numValues; valueIndex++) {
- const entry = [];
- for (let arrayIndex = 0; arrayIndex < numArrays; arrayIndex++) {
- entry.push(arrays[arrayIndex][valueIndex]);
- }
- result.push(entry);
- }
- return result;
-};
-
-/**
- * This method is like "zip" except that it accepts iteratee to
- * specify how grouped values should be combined. The iteratee is
- * invoked with the elements of each group.
- *
- * @returns {any[]}
- */
-export const zipWith = iterateeFn => (...arrays) => {
- return map(values => iterateeFn(...values))(zip(...arrays));
-};
diff --git a/tgui/packages/common/collections.spec.ts b/tgui/packages/common/collections.spec.ts
new file mode 100644
index 000000000000..58eff7f354c9
--- /dev/null
+++ b/tgui/packages/common/collections.spec.ts
@@ -0,0 +1,20 @@
+import { range, zip } from "./collections";
+
+// Type assertions, these will lint if the types are wrong.
+const _zip1: [string, number] = zip(["a"], [1])[0];
+
+describe("range", () => {
+ test("range(0, 5)", () => {
+ expect(range(0, 5)).toEqual([0, 1, 2, 3, 4]);
+ });
+});
+
+describe("zip", () => {
+ test("zip(['a', 'b', 'c'], [1, 2, 3, 4])", () => {
+ expect(zip(["a", "b", "c"], [1, 2, 3, 4])).toEqual([
+ ["a", 1],
+ ["b", 2],
+ ["c", 3],
+ ]);
+ });
+});
diff --git a/tgui/packages/common/collections.ts b/tgui/packages/common/collections.ts
index 40f610b36d6f..f0419253e242 100644
--- a/tgui/packages/common/collections.ts
+++ b/tgui/packages/common/collections.ts
@@ -4,64 +4,6 @@
* @license MIT
*/
-/**
- * Converts a given collection to an array.
- *
- * - Arrays are returned unmodified;
- * - If object was provided, keys will be discarded;
- * - Everything else will result in an empty array.
- *
- * @returns {any[]}
- */
-export const toArray = collection => {
- if (Array.isArray(collection)) {
- return collection;
- }
- if (typeof collection === 'object') {
- const hasOwnProperty = Object.prototype.hasOwnProperty;
- const result = [];
- for (let i in collection) {
- if (hasOwnProperty.call(collection, i)) {
- result.push(collection[i]);
- }
- }
- return result;
- }
- return [];
-};
-
-/**
- * Converts a given object to an array, and appends a key to every
- * object inside of that array.
- *
- * Example input (object):
- * ```
- * {
- * 'Foo': { info: 'Hello world!' },
- * 'Bar': { info: 'Hello world!' },
- * }
- * ```
- *
- * Example output (array):
- * ```
- * [
- * { key: 'Foo', info: 'Hello world!' },
- * { key: 'Bar', info: 'Hello world!' },
- * ]
- * ```
- *
- * @template T
- * @param {{ [key: string]: T }} obj Object, or in DM terms, an assoc array
- * @param {string} keyProp Property, to which key will be assigned
- * @returns {T[]} Array of keyed objects
- */
-export const toKeyedArray = (obj, keyProp = 'key') => {
- return map((item, key) => ({
- [keyProp]: key,
- ...item,
- }))(obj);
-};
-
/**
* Iterates over elements of collection, returning an array of all elements
* iteratee returns truthy for. The predicate is invoked with three
@@ -72,21 +14,40 @@ export const toKeyedArray = (obj, keyProp = 'key') => {
*
* @returns {any[]}
*/
-export const filter = iterateeFn => collection => {
- if (collection === null && collection === undefined) {
- return collection;
- }
- if (Array.isArray(collection)) {
- const result = [];
- for (let i = 0; i < collection.length; i++) {
- const item = collection[i];
- if (iterateeFn(item, i, collection)) {
- result.push(item);
+export const filter = (iterateeFn: (
+ input: T,
+ index: number,
+ collection: T[],
+) => boolean) =>
+ (collection: T[]): T[] => {
+ if (collection === null || collection === undefined) {
+ return collection;
}
- }
- return result;
- }
- throw new Error(`filter() can't iterate on type ${typeof collection}`);
+ if (Array.isArray(collection)) {
+ const result: T[] = [];
+ for (let i = 0; i < collection.length; i++) {
+ const item = collection[i];
+ if (iterateeFn(item, i, collection)) {
+ result.push(item);
+ }
+ }
+ return result;
+ }
+ throw new Error(`filter() can't iterate on type ${typeof collection}`);
+ };
+
+type MapFunction = {
+ (iterateeFn: (
+ value: T,
+ index: number,
+ collection: T[],
+ ) => U): (collection: T[]) => U[];
+
+ (iterateeFn: (
+ value: T,
+ index: K,
+ collection: Record,
+ ) => U): (collection: Record) => U[];
};
/**
@@ -96,31 +57,43 @@ export const filter = iterateeFn => collection => {
*
* If collection is 'null' or 'undefined', it will be returned "as is"
* without emitting any errors (which can be useful in some cases).
- *
- * @returns {any[]}
*/
-export const map = iterateeFn => collection => {
- if (collection === null && collection === undefined) {
- return collection;
- }
- if (Array.isArray(collection)) {
- const result = [];
- for (let i = 0; i < collection.length; i++) {
- result.push(iterateeFn(collection[i], i, collection));
+export const map: MapFunction = (iterateeFn) =>
+ (collection: T[]): U[] => {
+ if (collection === null || collection === undefined) {
+ return collection;
}
- return result;
- }
- if (typeof collection === 'object') {
- const hasOwnProperty = Object.prototype.hasOwnProperty;
- const result = [];
- for (let i in collection) {
- if (hasOwnProperty.call(collection, i)) {
- result.push(iterateeFn(collection[i], i, collection));
- }
+
+ if (Array.isArray(collection)) {
+ return collection.map(iterateeFn);
+ }
+
+ if (typeof collection === 'object') {
+ return Object.entries(collection).map(([key, value]) => {
+ return iterateeFn(value, key, collection);
+ });
+ }
+
+ 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 result;
}
- throw new Error(`map() can't iterate on type ${typeof collection}`);
+
+ return finalCollection;
};
const COMPARATOR = (objA, objB) => {
@@ -145,31 +118,47 @@ const COMPARATOR = (objA, objB) => {
* of running each element in a collection thru each iteratee.
*
* Iteratees are called with one argument (value).
- *
- * @returns {any[]}
*/
-export const sortBy = (...iterateeFns) => array => {
- if (!Array.isArray(array)) {
- return array;
- }
- let length = array.length;
- // Iterate over the array to collect criteria to sort it by
- let mappedArray = [];
- for (let i = 0; i < length; i++) {
- const value = array[i];
- mappedArray.push({
- criteria: iterateeFns.map(fn => fn(value)),
- value,
- });
- }
- // Sort criteria using the base comparator
- mappedArray.sort(COMPARATOR);
- // Unwrap values
- while (length--) {
- mappedArray[length] = mappedArray[length].value;
- }
- return mappedArray;
-};
+export const sortBy = (
+ ...iterateeFns: ((input: T) => unknown)[]
+) => (array: T[]): T[] => {
+ if (!Array.isArray(array)) {
+ return array;
+ }
+ let length = array.length;
+ // Iterate over the array to collect criteria to sort it by
+ let mappedArray: {
+ criteria: unknown[],
+ value: T,
+ }[] = [];
+ for (let i = 0; i < length; i++) {
+ const value = array[i];
+ mappedArray.push({
+ criteria: iterateeFns.map(fn => fn(value)),
+ value,
+ });
+ }
+ // Sort criteria using the base comparator
+ mappedArray.sort(COMPARATOR);
+
+ // Unwrap values
+ const values: T[] = [];
+ while (length--) {
+ values[length] = mappedArray[length].value;
+ }
+ return values;
+ };
+
+export const sort = sortBy();
+
+export const sortStrings = sortBy();
+
+/**
+ * Returns a range of numbers from start to end, exclusively.
+ * For example, range(0, 5) will return [0, 1, 2, 3, 4].
+ */
+export const range = (start: number, end: number): number[] =>
+ new Array(end - start).fill(null).map((_, index) => index + start);
/**
* A fast implementation of reduce.
@@ -203,58 +192,66 @@ export const reduce = (reducerFn, initialValue) => array => {
* is determined by the order they occur in the array. The iteratee is
* invoked with one argument: value.
*/
-export const uniqBy = iterateeFn => array => {
- const { length } = array;
- const result = [];
- const seen = iterateeFn ? [] : result;
- let index = -1;
- outer:
- while (++index < length) {
- let value = array[index];
- const computed = iterateeFn ? iterateeFn(value) : value;
- value = value !== 0 ? value : 0;
- if (computed === computed) {
- let seenIndex = seen.length;
- while (seenIndex--) {
- if (seen[seenIndex] === computed) {
- continue outer;
+export const uniqBy = (
+ iterateeFn?: (value: T) => unknown
+) => (array: T[]): T[] => {
+ const { length } = array;
+ const result: T[] = [];
+ const seen: unknown[] = iterateeFn ? [] : result;
+ let index = -1;
+ outer:
+ while (++index < length) {
+ let value: T | 0 = array[index];
+ const computed = iterateeFn ? iterateeFn(value) : value;
+ if (computed === computed) {
+ let seenIndex = seen.length;
+ while (seenIndex--) {
+ if (seen[seenIndex] === computed) {
+ continue outer;
+ }
}
+ if (iterateeFn) {
+ seen.push(computed);
+ }
+ result.push(value);
}
- if (iterateeFn) {
- seen.push(computed);
- }
- result.push(value);
- }
- else if (!seen.includes(computed)) {
- if (seen !== result) {
- seen.push(computed);
+ else if (!seen.includes(computed)) {
+ if (seen !== result) {
+ seen.push(computed);
+ }
+ result.push(value);
}
- result.push(value);
}
- }
- return result;
-};
+ return result;
+ };
+/* eslint-enable indent */
+
+export const uniq = uniqBy();
+
+type Zip = {
+ [I in keyof T]: T[I] extends (infer U)[] ? U : never;
+}[];
/**
* Creates an array of grouped elements, the first of which contains
* the first elements of the given arrays, the second of which contains
* the second elements of the given arrays, and so on.
- *
- * @returns {any[]}
*/
-export const zip = (...arrays) => {
+export const zip = (...arrays: T): Zip => {
if (arrays.length === 0) {
- return;
+ return [];
}
const numArrays = arrays.length;
const numValues = arrays[0].length;
- const result = [];
+ const result: Zip = [];
for (let valueIndex = 0; valueIndex < numValues; valueIndex++) {
- const entry = [];
+ const entry: unknown[] = [];
for (let arrayIndex = 0; arrayIndex < numArrays; arrayIndex++) {
entry.push(arrays[arrayIndex][valueIndex]);
}
- result.push(entry);
+
+ // I tried everything to remove this any, and have no idea how to do it.
+ result.push(entry as any);
}
return result;
};
@@ -263,13 +260,56 @@ export const zip = (...arrays) => {
* This method is like "zip" except that it accepts iteratee to
* specify how grouped values should be combined. The iteratee is
* invoked with the elements of each group.
- *
- * @returns {any[]}
*/
-export const zipWith = iterateeFn => (...arrays) => {
- return map(values => iterateeFn(...values))(zip(...arrays));
+export const zipWith = (iterateeFn: (...values: T[]) => U) =>
+ (...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;
+ };
+};
const isObject = (obj: unknown) => typeof obj === 'object' && obj !== null;
diff --git a/tgui/packages/common/color.js b/tgui/packages/common/color.js
index 4a62b97b8491..ba26187326d0 100644
--- a/tgui/packages/common/color.js
+++ b/tgui/packages/common/color.js
@@ -15,7 +15,12 @@ export class Color {
}
toString() {
- return `rgba(${this.r | 0}, ${this.g | 0}, ${this.b | 0}, ${this.a | 0})`;
+ // Alpha component needs to permit fractional values, so cannot use |
+ let alpha = parseFloat(this.a);
+ if (isNaN(alpha)) {
+ alpha = 1;
+ }
+ return `rgba(${this.r | 0}, ${this.g | 0}, ${this.b | 0}, ${alpha})`;
}
// Darkens a color by a given percent. Returns a color, which can have toString called to get it's rgba() css value.
diff --git a/tgui/packages/common/exhaustive.ts b/tgui/packages/common/exhaustive.ts
new file mode 100644
index 000000000000..bc41757515b0
--- /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/common/math.ts b/tgui/packages/common/math.ts
new file mode 100644
index 000000000000..97e6b60b2ed4
--- /dev/null
+++ b/tgui/packages/common/math.ts
@@ -0,0 +1,100 @@
+/**
+ * @file
+ * @copyright 2020 Aleksej Komarov
+ * @license MIT
+ */
+
+/**
+ * Limits a number to the range between 'min' and 'max'.
+ */
+export const clamp = (value, min, max) => {
+ return value < min ? min : value > max ? max : value;
+};
+
+/**
+ * Limits a number between 0 and 1.
+ */
+export const clamp01 = value => {
+ return value < 0 ? 0 : value > 1 ? 1 : value;
+};
+
+/**
+ * Scales a number to fit into the range between min and max.
+ */
+export const scale = (value, min, max) => {
+ return (value - min) / (max - min);
+};
+
+/**
+ * Robust number rounding.
+ *
+ * Adapted from Locutus, see: http://locutus.io/php/math/round/
+ *
+ * @param {number} value
+ * @param {number} precision
+ * @return {number}
+ */
+export const round = (value, precision) => {
+ if (!value || isNaN(value)) {
+ return value;
+ }
+ // helper variables
+ let m, f, isHalf, sgn;
+ // making sure precision is integer
+ precision |= 0;
+ m = Math.pow(10, precision);
+ value *= m;
+ // sign of the number
+ sgn = +(value > 0) | -(value < 0);
+ // isHalf = value % 1 === 0.5 * sgn;
+ isHalf = Math.abs(value % 1) >= 0.4999999999854481;
+ f = Math.floor(value);
+ if (isHalf) {
+ // rounds .5 away from zero
+ value = f + (sgn > 0);
+ }
+ return (isHalf ? value : Math.round(value)) / m;
+};
+
+/**
+ * Returns a string representing a number in fixed point notation.
+ */
+export const toFixed = (value, fractionDigits = 0) => {
+ return Number(value).toFixed(Math.max(fractionDigits, 0));
+};
+
+/**
+ * Checks whether a value is within the provided range.
+ *
+ * Range is an array of two numbers, for example: [0, 15].
+ */
+export const inRange = (value, range) => {
+ return range
+ && value >= range[0]
+ && value <= range[1];
+};
+
+/**
+ * Walks over the object with ranges, comparing value against every range,
+ * and returns the key of the first matching range.
+ *
+ * Range is an array of two numbers, for example: [0, 15].
+ */
+export const keyOfMatchingRange = (value, ranges) => {
+ for (let rangeName of Object.keys(ranges)) {
+ const range = ranges[rangeName];
+ if (inRange(value, range)) {
+ return rangeName;
+ }
+ }
+};
+
+/**
+ * Get number of digits following the decimal point in a number
+ */
+export const numberOfDecimalDigits = value => {
+ if (Math.floor(value) !== value) {
+ return value.toString().split('.')[1].length || 0;
+ }
+ return 0;
+};
diff --git a/tgui/packages/common/types.ts b/tgui/packages/common/types.ts
index e219bd3b7e12..a92ac122d9fe 100644
--- a/tgui/packages/common/types.ts
+++ b/tgui/packages/common/types.ts
@@ -1,6 +1,5 @@
/**
* Returns the arguments of a function F as an array.
*/
-// prettier-ignore
export type ArgumentsOf
= F extends (...args: infer A) => unknown ? A : never;
diff --git a/tgui/packages/tgfont/dist/tgfont.css b/tgui/packages/tgfont/dist/tgfont.css
index 7de15a0d7cf8..b30dd992813d 100644
--- a/tgui/packages/tgfont/dist/tgfont.css
+++ b/tgui/packages/tgfont/dist/tgfont.css
@@ -1,7 +1,7 @@
@font-face {
font-family: "tgfont";
- src: url("./tgfont.woff2?befe80cc7d69939d2283d48b84f181ef") format("woff2"),
-url("./tgfont.eot?befe80cc7d69939d2283d48b84f181ef#iefix") format("embedded-opentype");
+ src: url("./tgfont.woff2?bb59482417f65cea822ea95f849b2acf") format("woff2"),
+url("./tgfont.eot?bb59482417f65cea822ea95f849b2acf#iefix") format("embedded-opentype");
}
i[class^="tg-"]:before, i[class*=" tg-"]:before {
@@ -15,9 +15,39 @@ i[class^="tg-"]:before, i[class*=" tg-"]:before {
-moz-osx-font-smoothing: grayscale;
}
-.tg-nanotrasen_logo:before {
+.tg-air-tank-slash:before {
content: "\f101";
}
-.tg-syndicate_logo:before {
+.tg-air-tank:before {
content: "\f102";
}
+.tg-bad-touch:before {
+ content: "\f103";
+}
+.tg-image-minus:before {
+ content: "\f104";
+}
+.tg-image-plus:before {
+ content: "\f105";
+}
+.tg-nanotrasen-logo:before {
+ content: "\f106";
+}
+.tg-non-binary:before {
+ content: "\f107";
+}
+.tg-prosthetic-full:before {
+ content: "\f108";
+}
+.tg-prosthetic-leg:before {
+ content: "\f109";
+}
+.tg-sound-minus:before {
+ content: "\f10a";
+}
+.tg-sound-plus:before {
+ content: "\f10b";
+}
+.tg-syndicate-logo:before {
+ content: "\f10c";
+}
diff --git a/tgui/packages/tgfont/dist/tgfont.eot b/tgui/packages/tgfont/dist/tgfont.eot
index a6c3a57332b4..c8e092a4d3d1 100644
Binary files a/tgui/packages/tgfont/dist/tgfont.eot and b/tgui/packages/tgfont/dist/tgfont.eot differ
diff --git a/tgui/packages/tgfont/dist/tgfont.woff2 b/tgui/packages/tgfont/dist/tgfont.woff2
index 715dd3e572c8..87df9b8efb4c 100644
Binary files a/tgui/packages/tgfont/dist/tgfont.woff2 and b/tgui/packages/tgfont/dist/tgfont.woff2 differ
diff --git a/tgui/packages/tgfont/icons/ATTRIBUTIONS.md b/tgui/packages/tgfont/icons/ATTRIBUTIONS.md
new file mode 100644
index 000000000000..2f218388d364
--- /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/air-tank-slash.svg b/tgui/packages/tgfont/icons/air-tank-slash.svg
new file mode 100644
index 000000000000..37ffcb5b8809
--- /dev/null
+++ b/tgui/packages/tgfont/icons/air-tank-slash.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/air-tank.svg b/tgui/packages/tgfont/icons/air-tank.svg
new file mode 100644
index 000000000000..7d1e07747197
--- /dev/null
+++ b/tgui/packages/tgfont/icons/air-tank.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/bad-touch.svg b/tgui/packages/tgfont/icons/bad-touch.svg
new file mode 100644
index 000000000000..6dc3c9a718a7
--- /dev/null
+++ b/tgui/packages/tgfont/icons/bad-touch.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tgui/packages/tgfont/icons/image-minus.svg b/tgui/packages/tgfont/icons/image-minus.svg
new file mode 100644
index 000000000000..8c3231917ff9
--- /dev/null
+++ b/tgui/packages/tgfont/icons/image-minus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/image-plus.svg b/tgui/packages/tgfont/icons/image-plus.svg
new file mode 100644
index 000000000000..1658509429e3
--- /dev/null
+++ b/tgui/packages/tgfont/icons/image-plus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/nanotrasen-logo.svg b/tgui/packages/tgfont/icons/nanotrasen-logo.svg
new file mode 100644
index 000000000000..b74a415d4d2b
--- /dev/null
+++ b/tgui/packages/tgfont/icons/nanotrasen-logo.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/tgui/packages/tgfont/icons/nanotrasen_logo.svg b/tgui/packages/tgfont/icons/nanotrasen_logo.svg
deleted file mode 100644
index d21b9f0a2a0f..000000000000
--- a/tgui/packages/tgfont/icons/nanotrasen_logo.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/tgui/packages/tgfont/icons/non-binary.svg b/tgui/packages/tgfont/icons/non-binary.svg
new file mode 100644
index 000000000000..9aaec674bbbc
--- /dev/null
+++ b/tgui/packages/tgfont/icons/non-binary.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tgui/packages/tgfont/icons/prosthetic-full.svg b/tgui/packages/tgfont/icons/prosthetic-full.svg
new file mode 100644
index 000000000000..7d221244edcc
--- /dev/null
+++ b/tgui/packages/tgfont/icons/prosthetic-full.svg
@@ -0,0 +1,27 @@
+
+image/svg+xml
+
+
diff --git a/tgui/packages/tgfont/icons/prosthetic-leg.svg b/tgui/packages/tgfont/icons/prosthetic-leg.svg
new file mode 100644
index 000000000000..c1f6ceee3fc3
--- /dev/null
+++ b/tgui/packages/tgfont/icons/prosthetic-leg.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
diff --git a/tgui/packages/tgfont/icons/sound-minus.svg b/tgui/packages/tgfont/icons/sound-minus.svg
new file mode 100644
index 000000000000..df51179d4b53
--- /dev/null
+++ b/tgui/packages/tgfont/icons/sound-minus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/sound-plus.svg b/tgui/packages/tgfont/icons/sound-plus.svg
new file mode 100644
index 000000000000..c5f40d53b560
--- /dev/null
+++ b/tgui/packages/tgfont/icons/sound-plus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tgui/packages/tgfont/icons/syndicate-logo.svg b/tgui/packages/tgfont/icons/syndicate-logo.svg
new file mode 100644
index 000000000000..eda92f9b3082
--- /dev/null
+++ b/tgui/packages/tgfont/icons/syndicate-logo.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/tgui/packages/tgfont/icons/syndicate_logo.svg b/tgui/packages/tgfont/icons/syndicate_logo.svg
deleted file mode 100644
index c2863b790df3..000000000000
--- a/tgui/packages/tgfont/icons/syndicate_logo.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/tgui/packages/tgui-panel/Panel.js b/tgui/packages/tgui-panel/Panel.js
index 927d793b17ab..d6cc9eb7efc8 100644
--- a/tgui/packages/tgui-panel/Panel.js
+++ b/tgui/packages/tgui-panel/Panel.js
@@ -4,11 +4,11 @@
* @license MIT
*/
-import { Button, Flex, Section } from 'tgui/components';
+import { Button, Section, Stack } from 'tgui/components';
import { Pane } from 'tgui/layouts';
import { NowPlayingWidget, useAudio } from './audio';
import { ChatPanel, ChatTabs } from './chat';
-import { gameReducer, useGame } from './game';
+import { useGame } from './game';
import { Notifications } from './Notifications';
import { PingIndicator } from './ping';
import { SettingsPanel, useSettings } from './settings';
@@ -34,19 +34,17 @@ export const Panel = (props, context) => {
}
return (
-
-
+
+
+
-
-
+
+
{
tooltip="Music player"
tooltipPosition="bottom-start"
onClick={() => audio.toggle()} />
-
-
+
+
{
: 'Open settings'}
tooltipPosition="bottom-start"
onClick={() => settings.toggle()} />
-
-
+
+
-
+
{audio.visible && (
-
+
-
+
)}
{settings.visible && (
-
+
-
+
)}
-
+
@@ -96,21 +94,19 @@ export const Panel = (props, context) => {
)}>
You are either AFK, experiencing lag or the connection
- has closed. If the server has been nuked, you
- are just lagging, you should be fine in a moment.
+ has closed.
)}
- {game.reconnectTimer > 0 && (
+ {game.roundRestartedAt && (
The connection has been closed because the server is
- restarting. Please wait while you are automatically reconnected
- in {game.reconnectTimer} Seconds.
+ restarting. Please wait while you automatically reconnect.
)}
-
-
+
+
);
};
@@ -132,9 +128,7 @@ const HoboPanel = (props, context) => {
Settings
{settings.visible && (
-
-
-
+
) || (
)}
diff --git a/tgui/packages/tgui-panel/chat/ChatPageSettings.js b/tgui/packages/tgui-panel/chat/ChatPageSettings.js
index 278fbab8f2f2..ba045983d2f3 100644
--- a/tgui/packages/tgui-panel/chat/ChatPageSettings.js
+++ b/tgui/packages/tgui-panel/chat/ChatPageSettings.js
@@ -5,7 +5,7 @@
*/
import { useDispatch, useSelector } from 'common/redux';
-import { Button, Collapsible, Divider, Stack, Input, Section } from 'tgui/components';
+import { Button, Collapsible, Divider, Input, Section, Stack } from 'tgui/components';
import { removeChatPage, toggleAcceptedType, updateChatPage } from './actions';
import { MESSAGE_TYPES } from './constants';
import { selectCurrentChatPage } from './selectors';
diff --git a/tgui/packages/tgui-panel/chat/middleware.js b/tgui/packages/tgui-panel/chat/middleware.js
index bb903f018aca..6cfc72bd2994 100644
--- a/tgui/packages/tgui-panel/chat/middleware.js
+++ b/tgui/packages/tgui-panel/chat/middleware.js
@@ -4,6 +4,7 @@
* @license MIT
*/
+import DOMPurify from 'dompurify';
import { storage } from 'common/storage';
import { loadSettings, updateSettings } from '../settings/actions';
import { selectSettings } from '../settings/selectors';
@@ -13,6 +14,14 @@ import { createMessage, serializeMessage } from './model';
import { chatRenderer } from './renderer';
import { selectChat, selectCurrentChatPage } from './selectors';
+// List of blacklisted tags
+const FORBID_TAGS = [
+ 'a',
+ 'iframe',
+ 'link',
+ 'video',
+];
+
const saveChatToStorage = async store => {
const state = selectChat(store.getState());
const fromIndex = Math.max(0,
@@ -30,11 +39,18 @@ const loadChatFromStorage = async store => {
storage.get('chat-messages'),
]);
// Discard incompatible versions
- if (state && state.version <= 5) {
+ if (state && state.version <= 4) {
store.dispatch(loadChat());
return;
}
if (messages) {
+ for (let message of messages) {
+ if (message.html) {
+ message.html = DOMPurify.sanitize(message.html, {
+ FORBID_TAGS,
+ });
+ }
+ }
const batch = [
...messages,
createMessage({
diff --git a/tgui/packages/tgui-panel/chat/model.js b/tgui/packages/tgui-panel/chat/model.js
index 035bd0cf2781..7400d5e13cd3 100644
--- a/tgui/packages/tgui-panel/chat/model.js
+++ b/tgui/packages/tgui-panel/chat/model.js
@@ -11,14 +11,22 @@ export const canPageAcceptType = (page, type) => (
type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type]
);
-export const createPage = obj => ({
- id: createUuid(),
- name: 'New Tab',
- acceptedTypes: {},
- unreadCount: 0,
- createdAt: Date.now(),
- ...obj,
-});
+export const createPage = obj => {
+ let acceptedTypes = {};
+
+ for (let typeDef of MESSAGE_TYPES) {
+ acceptedTypes[typeDef.type] = !!typeDef.important;
+ }
+
+ return {
+ id: createUuid(),
+ name: 'New Tab',
+ acceptedTypes: acceptedTypes,
+ unreadCount: 0,
+ createdAt: Date.now(),
+ ...obj,
+ };
+};
export const createMainPage = () => {
const acceptedTypes = {};
diff --git a/tgui/packages/tgui-panel/chat/reducer.js b/tgui/packages/tgui-panel/chat/reducer.js
index d4b7b809689c..0188b1fabe8b 100644
--- a/tgui/packages/tgui-panel/chat/reducer.js
+++ b/tgui/packages/tgui-panel/chat/reducer.js
@@ -10,7 +10,7 @@ import { canPageAcceptType, createMainPage } from './model';
const mainPage = createMainPage();
export const initialState = {
- version: 6,
+ version: 5,
currentPageId: mainPage.id,
scrollTracking: true,
pages: [
@@ -28,6 +28,19 @@ export const chatReducer = (state = initialState, action) => {
if (payload?.version !== state.version) {
return state;
}
+ // Enable any filters that are not explicitly set, that are
+ // enabled by default on the main page.
+ // NOTE: This mutates acceptedTypes on the state.
+ for (let id of Object.keys(payload.pageById)) {
+ const page = payload.pageById[id];
+ const filters = page.acceptedTypes;
+ const defaultFilters = mainPage.acceptedTypes;
+ for (let type of Object.keys(defaultFilters)) {
+ if (filters[type] === undefined) {
+ filters[type] = defaultFilters[type];
+ }
+ }
+ }
// Reset page message counts
// NOTE: We are mutably changing the payload on the assumption
// that it is a copy that comes straight from the web storage.
diff --git a/tgui/packages/tgui-panel/chat/renderer.js b/tgui/packages/tgui-panel/chat/renderer.js
index 3825e94c846b..b95066116053 100644
--- a/tgui/packages/tgui-panel/chat/renderer.js
+++ b/tgui/packages/tgui-panel/chat/renderer.js
@@ -8,8 +8,10 @@ import { EventEmitter } from 'common/events';
import { classes } from 'common/react';
import { createLogger } from 'tgui/logging';
import { COMBINE_MAX_MESSAGES, COMBINE_MAX_TIME_WINDOW, IMAGE_RETRY_DELAY, IMAGE_RETRY_LIMIT, IMAGE_RETRY_MESSAGE_AGE, MAX_PERSISTED_MESSAGES, MAX_VISIBLE_MESSAGES, MESSAGE_PRUNE_INTERVAL, MESSAGE_TYPES, MESSAGE_TYPE_INTERNAL, MESSAGE_TYPE_UNKNOWN } from './constants';
+import { render } from 'inferno';
import { canPageAcceptType, createMessage, isSameMessage } from './model';
import { highlightNode, linkifyNode } from './replaceInTextNode';
+import { Tooltip } from "../../tgui/components";
const logger = createLogger('chatRenderer');
@@ -17,6 +19,18 @@ const logger = createLogger('chatRenderer');
// that is still trackable.
const SCROLL_TRACKING_TOLERANCE = 24;
+// List of injectable component names to the actual type
+export const TGUI_CHAT_COMPONENTS = {
+ Tooltip,
+};
+
+// List of injectable attibute names mapped to their proper prop
+// We need this because attibutes don't support lowercase names
+export const TGUI_CHAT_ATTRIBUTES_TO_PROPS = {
+ "position": "position",
+ "content": "content",
+};
+
const findNearestScrollableParent = startingNode => {
const body = document.body;
let node = startingNode;
@@ -176,7 +190,6 @@ class ChatRenderer {
this.highlightColor = null;
return;
}
- // const allowedRegex = /^[a-z0-9_-\s]+$/ig;
const lines = String(text)
.split(',')
// eslint-disable-next-line no-useless-escape
@@ -302,6 +315,51 @@ class ChatRenderer {
else {
logger.error('Error: message is missing text payload', message);
}
+ // Get all nodes in this message that want to be rendered like jsx
+ const nodes = node.querySelectorAll("[data-component]");
+ for (let i = 0; i < nodes.length; i++) {
+ const childNode = nodes[i];
+ const targetName = childNode.getAttribute("data-component");
+ // Let's pull out the attibute info we need
+ let outputProps = {};
+ for (let j = 0; j < childNode.attributes.length; j++) {
+ const attribute = childNode.attributes[j];
+
+ let working_value = attribute.nodeValue;
+ // We can't do the "if it has no value it's truthy" trick
+ // Because getAttribute returns "", not null. Hate IE
+ if (working_value === "$true") {
+ working_value = true;
+ }
+ else if (working_value === "$false") {
+ working_value = false;
+ }
+ else if (!isNaN(working_value)) {
+ const parsed_float = parseFloat(working_value);
+ if (!isNaN(parsed_float)) {
+ working_value = parsed_float;
+ }
+ }
+
+ let canon_name = attribute.nodeName.replace("data-", "");
+ // html attributes don't support upper case chars, so we need to map
+ canon_name = TGUI_CHAT_ATTRIBUTES_TO_PROPS[canon_name];
+ outputProps[canon_name] = working_value;
+ }
+ const oldHtml = { __html: childNode.innerHTML };
+ while (childNode.firstChild) {
+ childNode.removeChild(childNode.firstChild);
+ }
+ const Element = TGUI_CHAT_COMPONENTS[targetName];
+ /* eslint-disable react/no-danger */
+ render(
+
+
+ , childNode);
+ /* eslint-enable react/no-danger */
+
+ }
+
// Highlight text
if (!message.avoidHighlighting && this.highlightRegex) {
const highlighted = highlightNode(node,
@@ -450,7 +508,7 @@ class ChatRenderer {
cssText += 'body, html { background-color: #141414 }\n';
// Compile chat log as HTML text
let messagesHtml = '';
- for (let message of this.messages) {
+ for (let message of this.visibleMessages) {
if (message.node) {
messagesHtml += message.node.outerHTML + '\n';
}
diff --git a/tgui/packages/tgui-panel/game/actions.js b/tgui/packages/tgui-panel/game/actions.js
index 4fb425267de2..e40014c44b04 100644
--- a/tgui/packages/tgui-panel/game/actions.js
+++ b/tgui/packages/tgui-panel/game/actions.js
@@ -7,6 +7,5 @@
import { createAction } from 'common/redux';
export const roundRestarted = createAction('roundrestart');
-export const reconnected = createAction('reconnected');
export const connectionLost = createAction('game/connectionLost');
export const connectionRestored = createAction('game/connectionRestored');
diff --git a/tgui/packages/tgui-panel/game/reducer.js b/tgui/packages/tgui-panel/game/reducer.js
index b3853bb2dfb5..97535524c560 100644
--- a/tgui/packages/tgui-panel/game/reducer.js
+++ b/tgui/packages/tgui-panel/game/reducer.js
@@ -6,7 +6,6 @@
import { connectionLost } from './actions';
import { connectionRestored } from './actions';
-import { reconnected } from './actions';
const initialState = {
// TODO: This is where round info should be.
@@ -14,9 +13,6 @@ const initialState = {
roundTime: null,
roundRestartedAt: null,
connectionLostAt: null,
- rebooting: false,
- reconnectTimer: 0,
- reconnected: false,
};
export const gameReducer = (state = initialState, action) => {
@@ -25,23 +21,8 @@ export const gameReducer = (state = initialState, action) => {
return {
...state,
roundRestartedAt: meta.now,
- rebooting: true,
- reconnectTimer: 14,
- reconnected: false,
- tryingtoreconnect: true,
};
}
- if (type === 'reconnected') {
- return {
- ...state,
- reconnected: true,
- rebooting: false,
- };
- }
- if (state.rebooting === true && state.tryingtoreconnect === true) {
- setInterval(() => { reconnectplease(); }, 10000);
- state.tryingtoreconnect = false;
- }
if (type === connectionLost.type) {
return {
...state,
@@ -54,10 +35,5 @@ export const gameReducer = (state = initialState, action) => {
connectionLostAt: null,
};
}
- let reconnectplease = function () {
- if (state.reconnected === false) {
- Byond.command('.reconnect');
- }
- };
return state;
};
diff --git a/tgui/packages/tgui-panel/index.js b/tgui/packages/tgui-panel/index.js
index 020b7b59b329..721fa97fb50c 100644
--- a/tgui/packages/tgui-panel/index.js
+++ b/tgui/packages/tgui-panel/index.js
@@ -68,20 +68,11 @@ const setupApp = () => {
setupPanelFocusHacks();
captureExternalLinks();
- // Subscribe for Redux state updates
+ // Re-render UI on store updates
store.subscribe(renderApp);
- // Subscribe for bankend updates
- window.update = msg => store.dispatch(Byond.parseJson(msg));
-
- // Process the early update queue
- while (true) {
- const msg = window.__updateQueue__.shift();
- if (!msg) {
- break;
- }
- window.update(msg);
- }
+ // Dispatch incoming messages as store actions
+ Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
// Unhide the panel
Byond.winset('output', {
@@ -94,6 +85,13 @@ const setupApp = () => {
'size': '0x0',
});
+ // Resize the panel to match the non-browser output
+ Byond.winget('output').then(output => {
+ Byond.winset('browseroutput', {
+ 'size': output.size,
+ });
+ });
+
// Enable hot module reloading
if (module.hot) {
setupHotReloading();
diff --git a/tgui/packages/tgui-panel/ping/middleware.js b/tgui/packages/tgui-panel/ping/middleware.js
index fdd841095719..2d607c841740 100644
--- a/tgui/packages/tgui-panel/ping/middleware.js
+++ b/tgui/packages/tgui-panel/ping/middleware.js
@@ -4,13 +4,13 @@
* @license MIT
*/
-import { sendMessage } from 'tgui/backend';
import { pingFail, pingSuccess } from './actions';
import { PING_INTERVAL, PING_QUEUE_SIZE, PING_TIMEOUT } from './constants';
export const pingMiddleware = store => {
let initialized = false;
let index = 0;
+ let interval;
const pings = [];
const sendPing = () => {
for (let i = 0; i < PING_QUEUE_SIZE; i++) {
@@ -22,19 +22,22 @@ export const pingMiddleware = store => {
}
const ping = { index, sentAt: Date.now() };
pings[index] = ping;
- sendMessage({
- type: 'ping',
- payload: { index },
- });
+ Byond.sendMessage('ping', { index });
index = (index + 1) % PING_QUEUE_SIZE;
};
return next => action => {
const { type, payload } = action;
if (!initialized) {
initialized = true;
- setInterval(sendPing, PING_INTERVAL);
+ interval = setInterval(sendPing, PING_INTERVAL);
sendPing();
}
+ if (type === 'roundrestart') {
+ // Stop pinging because dreamseeker is currently reconnecting.
+ // Topic calls in the middle of reconnect will crash the connection.
+ clearInterval(interval);
+ return next(action);
+ }
if (type === 'pingReply') {
const { index } = payload;
const ping = pings[index];
diff --git a/tgui/packages/tgui-panel/settings/SettingsPanel.js b/tgui/packages/tgui-panel/settings/SettingsPanel.js
index d449fd3e7a68..01df419ce21f 100644
--- a/tgui/packages/tgui-panel/settings/SettingsPanel.js
+++ b/tgui/packages/tgui-panel/settings/SettingsPanel.js
@@ -23,17 +23,13 @@ export const SettingsPanel = (props, context) => {
- {SETTINGS_TABS.map((tab) => (
+ {SETTINGS_TABS.map(tab => (
- dispatch(
- changeSettingsTab({
- tabId: tab.id,
- })
- )
- }>
+ onClick={() => dispatch(changeSettingsTab({
+ tabId: tab.id,
+ }))}>
{tab.name}
))}
@@ -41,8 +37,12 @@ export const SettingsPanel = (props, context) => {
- {activeTab === 'general' && }
- {activeTab === 'chatPage' && }
+ {activeTab === 'general' && (
+
+ )}
+ {activeTab === 'chatPage' && (
+
+ )}
);
@@ -60,7 +60,7 @@ export const SettingsGeneral = (props, context) => {
matchCase,
} = useSelector(context, selectSettings);
const dispatch = useDispatch(context);
- const [freeFont, setFreeFont] = useLocalState(context, 'freeFont', false);
+ const [freeFont, setFreeFont] = useLocalState(context, "freeFont", false);
return (
@@ -68,48 +68,34 @@ export const SettingsGeneral = (props, context) => {
- dispatch(
- updateSettings({
- theme: value,
- })
- )
- }
- />
+ onSelected={value => dispatch(updateSettings({
+ theme: value,
+ }))} />
- {(!freeFont && (
+ {!freeFont && (
- dispatch(
- updateSettings({
- fontFamily: value,
- })
- )
- }
- />
- )) || (
+ onSelected={value => dispatch(updateSettings({
+ fontFamily: value,
+ }))} />
+ ) || (
- dispatch(
- updateSettings({
- fontFamily: value,
- })
- )
- }
+ onChange={(e, value) => dispatch(updateSettings({
+ fontFamily: value,
+ }))}
/>
)}
{
setFreeFont(!freeFont);
@@ -127,15 +113,10 @@ export const SettingsGeneral = (props, context) => {
maxValue={32}
value={fontSize}
unit="px"
- format={(value) => toFixed(value)}
- onChange={(e, value) =>
- dispatch(
- updateSettings({
- fontSize: value,
- })
- )
- }
- />
+ format={value => toFixed(value)}
+ onChange={(e, value) => dispatch(updateSettings({
+ fontSize: value,
+ }))} />
{
minValue={0.8}
maxValue={5}
value={lineHeight}
- format={(value) => toFixed(value, 2)}
- onDrag={(e, value) =>
- dispatch(
- updateSettings({
- lineHeight: value,
- })
- )
- }
- />
+ format={value => toFixed(value, 2)}
+ onDrag={(e, value) => dispatch(updateSettings({
+ lineHeight: value,
+ }))} />
- Highlight text (comma separated):
+
+ Highlight text (comma separated):
+
{
monospace
placeholder="#ffffff"
value={highlightColor}
- onInput={(e, value) =>
- dispatch(
- updateSettings({
- highlightColor: value,
- })
- )
- }
- />
+ onInput={(e, value) => dispatch(updateSettings({
+ highlightColor: value,
+ }))} />
- dispatch(rebuildChat())}>
+ dispatch(rebuildChat())}>
Apply now
@@ -223,7 +185,9 @@ export const SettingsGeneral = (props, context) => {
- dispatch(saveChatToDisk())}>
+ dispatch(saveChatToDisk())}>
Save chat log
diff --git a/tgui/packages/tgui-panel/settings/constants.js b/tgui/packages/tgui-panel/settings/constants.js
index 4d7e17860c26..af446fad58ee 100644
--- a/tgui/packages/tgui-panel/settings/constants.js
+++ b/tgui/packages/tgui-panel/settings/constants.js
@@ -15,7 +15,8 @@ export const SETTINGS_TABS = [
},
];
-export const FONTS_DISABLED = 'Default';
+
+export const FONTS_DISABLED = "Default";
export const FONTS = [
FONTS_DISABLED,
diff --git a/tgui/packages/tgui-panel/settings/hooks.js b/tgui/packages/tgui-panel/settings/hooks.js
index da46322f9a94..1cdcaac7364c 100644
--- a/tgui/packages/tgui-panel/settings/hooks.js
+++ b/tgui/packages/tgui-panel/settings/hooks.js
@@ -8,13 +8,13 @@ import { useDispatch, useSelector } from 'common/redux';
import { updateSettings, toggleSettings } from './actions';
import { selectSettings } from './selectors';
-export const useSettings = (context) => {
+export const useSettings = context => {
const settings = useSelector(context, selectSettings);
const dispatch = useDispatch(context);
return {
...settings,
visible: settings.view.visible,
toggle: () => dispatch(toggleSettings()),
- update: (obj) => dispatch(updateSettings(obj)),
+ update: obj => dispatch(updateSettings(obj)),
};
};
diff --git a/tgui/packages/tgui-panel/settings/middleware.js b/tgui/packages/tgui-panel/settings/middleware.js
index 0adecf7042c0..b5ce06c5cc08 100644
--- a/tgui/packages/tgui-panel/settings/middleware.js
+++ b/tgui/packages/tgui-panel/settings/middleware.js
@@ -10,25 +10,29 @@ import { loadSettings, updateSettings } from './actions';
import { selectSettings } from './selectors';
import { FONTS_DISABLED } from './constants';
-const setGlobalFontSize = (fontSize) => {
- document.documentElement.style.setProperty('font-size', fontSize + 'px');
- document.body.style.setProperty('font-size', fontSize + 'px');
+const setGlobalFontSize = fontSize => {
+ document.documentElement.style
+ .setProperty('font-size', fontSize + 'px');
+ document.body.style
+ .setProperty('font-size', fontSize + 'px');
};
-const setGlobalFontFamily = (fontFamily) => {
+const setGlobalFontFamily = fontFamily => {
if (fontFamily === FONTS_DISABLED) fontFamily = null;
- document.documentElement.style.setProperty('font-family', fontFamily);
- document.body.style.setProperty('font-family', fontFamily);
+ document.documentElement.style
+ .setProperty('font-family', fontFamily);
+ document.body.style
+ .setProperty('font-family', fontFamily);
};
-export const settingsMiddleware = (store) => {
+export const settingsMiddleware = store => {
let initialized = false;
- return (next) => (action) => {
+ return next => action => {
const { type, payload } = action;
if (!initialized) {
initialized = true;
- storage.get('panel-settings').then((settings) => {
+ storage.get('panel-settings').then(settings => {
store.dispatch(loadSettings(settings));
});
}
diff --git a/tgui/packages/tgui-panel/settings/selectors.js b/tgui/packages/tgui-panel/settings/selectors.js
index 6b5ca76bff0c..b1504457cfcf 100644
--- a/tgui/packages/tgui-panel/settings/selectors.js
+++ b/tgui/packages/tgui-panel/settings/selectors.js
@@ -4,5 +4,5 @@
* @license MIT
*/
-export const selectSettings = (state) => state.settings;
-export const selectActiveTab = (state) => state.settings.view.activeTab;
+export const selectSettings = state => state.settings;
+export const selectActiveTab = state => state.settings.view.activeTab;
diff --git a/tgui/packages/tgui-panel/styles/components/Chat.scss b/tgui/packages/tgui-panel/styles/components/Chat.scss
index 6a033189c7eb..b439ace8c58f 100644
--- a/tgui/packages/tgui-panel/styles/components/Chat.scss
+++ b/tgui/packages/tgui-panel/styles/components/Chat.scss
@@ -27,7 +27,7 @@ $color-bg-section: base.$color-bg-section !default;
vertical-align: middle;
background-color: crimson;
border-radius: 10px;
- transition: font-size 200ms;
+ transition: font-size 200ms ease-out;
&:before {
content: 'x';
diff --git a/tgui/packages/tgui-panel/styles/components/Ping.scss b/tgui/packages/tgui-panel/styles/components/Ping.scss
index f5aa0c393379..251b0fd95391 100644
--- a/tgui/packages/tgui-panel/styles/components/Ping.scss
+++ b/tgui/packages/tgui-panel/styles/components/Ping.scss
@@ -4,7 +4,7 @@
*/
@use 'sass:math';
-
+
$border-color: rgba(140, 140, 140, 0.5) !default;
.Ping {
diff --git a/tgui/packages/tgui-panel/styles/main.scss b/tgui/packages/tgui-panel/styles/main.scss
index 6f090392aba8..8114dcc6c6b4 100644
--- a/tgui/packages/tgui-panel/styles/main.scss
+++ b/tgui/packages/tgui-panel/styles/main.scss
@@ -40,6 +40,7 @@
@include meta.load-css('~tgui/styles/components/ProgressBar.scss');
@include meta.load-css('~tgui/styles/components/Section.scss');
@include meta.load-css('~tgui/styles/components/Slider.scss');
+@include meta.load-css('~tgui/styles/components/Stack.scss');
@include meta.load-css('~tgui/styles/components/Table.scss');
@include meta.load-css('~tgui/styles/components/Tabs.scss');
@include meta.load-css('~tgui/styles/components/TextArea.scss');
diff --git a/tgui/packages/tgui-panel/styles/themes/light.scss b/tgui/packages/tgui-panel/styles/themes/light.scss
index f59fd3ca6273..41cd8c888b56 100644
--- a/tgui/packages/tgui-panel/styles/themes/light.scss
+++ b/tgui/packages/tgui-panel/styles/themes/light.scss
@@ -32,29 +32,23 @@
@include meta.load-css('~tgui/styles/atomic/color.scss');
// Components
- @include meta.load-css(
- '~tgui/styles/components/Tabs.scss',
- $with: ('text-color': rgba(0, 0, 0, 0.5), 'color-default': rgba(0, 0, 0, 1))
- );
+ @include meta.load-css('~tgui/styles/components/Tabs.scss', $with: (
+ 'text-color': rgba(0, 0, 0, 0.5),
+ 'color-default': rgba(0, 0, 0, 1),
+ ));
@include meta.load-css('~tgui/styles/components/Section.scss');
- @include meta.load-css(
- '~tgui/styles/components/Button.scss',
- $with: (
- 'color-default': #bbbbbb,
- 'color-disabled': #363636,
- 'color-selected': #0668b8,
- 'color-caution': #be6209,
- 'color-danger': #9a9d00,
- 'color-transparent-text': rgba(0, 0, 0, 0.5)
- )
- );
- @include meta.load-css(
- '~tgui/styles/components/Input.scss',
- $with: (
- 'border-color': colors.fg(colors.$label),
- 'background-color': #ffffff
- )
- );
+ @include meta.load-css('~tgui/styles/components/Button.scss', $with: (
+ 'color-default': #bbbbbb,
+ 'color-disabled': #363636,
+ 'color-selected': #0668b8,
+ 'color-caution': #be6209,
+ 'color-danger': #9a9d00,
+ 'color-transparent-text': rgba(0, 0, 0, 0.5),
+ ));
+ @include meta.load-css('~tgui/styles/components/Input.scss', $with: (
+ 'border-color': colors.fg(colors.$label),
+ 'background-color': #ffffff,
+ ));
@include meta.load-css('~tgui/styles/components/NumberInput.scss');
@include meta.load-css('~tgui/styles/components/TextArea.scss');
@include meta.load-css('~tgui/styles/components/Knob.scss');
@@ -62,25 +56,20 @@
@include meta.load-css('~tgui/styles/components/ProgressBar.scss');
// Components specific to tgui-panel
- @include meta.load-css(
- '../components/Chat.scss',
- $with: ('text-color': #000000)
- );
+ @include meta.load-css('../components/Chat.scss', $with: (
+ 'text-color': #000000,
+ ));
// Layouts
- @include meta.load-css(
- '~tgui/styles/layouts/Layout.scss',
- $with: ('scrollbar-color-multiplier': -1)
- );
+ @include meta.load-css('~tgui/styles/layouts/Layout.scss', $with: (
+ 'scrollbar-color-multiplier': -1,
+ ));
@include meta.load-css('~tgui/styles/layouts/Window.scss');
- @include meta.load-css(
- '~tgui/styles/layouts/TitleBar.scss',
- $with: (
- 'text-color': rgba(0, 0, 0, 0.75),
- 'background-color': base.$color-bg,
- 'shadow-color-core': rgba(0, 0, 0, 0.25)
- )
- );
+ @include meta.load-css('~tgui/styles/layouts/TitleBar.scss', $with: (
+ 'text-color': rgba(0, 0, 0, 0.75),
+ 'background-color': base.$color-bg,
+ 'shadow-color-core': rgba(0, 0, 0, 0.25),
+ ));
// Goonchat styles
@include meta.load-css('../goon/chat-light.scss');
diff --git a/tgui/packages/tgui-panel/telemetry.js b/tgui/packages/tgui-panel/telemetry.js
index 31b8541c21cf..afad245c48a3 100644
--- a/tgui/packages/tgui-panel/telemetry.js
+++ b/tgui/packages/tgui-panel/telemetry.js
@@ -4,7 +4,6 @@
* @license MIT
*/
-import { sendMessage } from 'tgui/backend';
import { storage } from 'common/storage';
import { createLogger } from 'tgui/logging';
@@ -34,14 +33,8 @@ export const telemetryMiddleware = store => {
logger.debug('sending');
const limits = payload?.limits || {};
// Trim connections according to the server limit
- const connections = telemetry.connections
- .slice(0, limits.connections);
- sendMessage({
- type: 'telemetry',
- payload: {
- connections,
- },
- });
+ const connections = telemetry.connections.slice(0, limits.connections);
+ Byond.sendMessage('telemetry', { connections });
return;
}
// Keep telemetry up to date
diff --git a/tgui/packages/tgui-polyfill/html5shiv.js b/tgui/packages/tgui-polyfill/00-html5shiv.js
similarity index 100%
rename from tgui/packages/tgui-polyfill/html5shiv.js
rename to tgui/packages/tgui-polyfill/00-html5shiv.js
diff --git a/tgui/packages/tgui-polyfill/ie8.js b/tgui/packages/tgui-polyfill/01-ie8.js
similarity index 99%
rename from tgui/packages/tgui-polyfill/ie8.js
rename to tgui/packages/tgui-polyfill/01-ie8.js
index af4b3a5ee851..6a76f18fd096 100644
--- a/tgui/packages/tgui-polyfill/ie8.js
+++ b/tgui/packages/tgui-polyfill/01-ie8.js
@@ -369,7 +369,7 @@
var
self = this,
ontype = 'on' + type,
- temple = self[SECRET] ||
+ temple = self[SECRET] ||
defineProperty(
self, SECRET, {value: {}}
)[SECRET],
@@ -440,7 +440,7 @@
var
self = this,
ontype = 'on' + e.type,
- temple = self[SECRET],
+ temple = self[SECRET],
currentType = temple && temple[ontype],
valid = !!currentType,
parentNode
@@ -466,7 +466,7 @@
var
self = this,
ontype = 'on' + type,
- temple = self[SECRET],
+ temple = self[SECRET],
currentType = temple && temple[ontype],
handlers = currentType && currentType.h,
i = handlers ? find(handlers, handler) : -1
@@ -500,7 +500,7 @@
var
self = this,
ontype = 'on' + type,
- temple = self[SECRET] ||
+ temple = self[SECRET] ||
defineProperty(
self, SECRET, {value: {}}
)[SECRET],
@@ -522,7 +522,7 @@
var
self = this,
ontype = 'on' + e.type,
- temple = self[SECRET],
+ temple = self[SECRET],
currentType = temple && temple[ontype],
valid = !!currentType
;
diff --git a/tgui/packages/tgui-polyfill/dom4.js b/tgui/packages/tgui-polyfill/02-dom4.js
similarity index 100%
rename from tgui/packages/tgui-polyfill/dom4.js
rename to tgui/packages/tgui-polyfill/02-dom4.js
diff --git a/tgui/packages/tgui-polyfill/css-om.js b/tgui/packages/tgui-polyfill/03-css-om.js
similarity index 100%
rename from tgui/packages/tgui-polyfill/css-om.js
rename to tgui/packages/tgui-polyfill/03-css-om.js
diff --git a/tgui/packages/tgui-polyfill/10-misc.js b/tgui/packages/tgui-polyfill/10-misc.js
new file mode 100644
index 000000000000..5a1849e13558
--- /dev/null
+++ b/tgui/packages/tgui-polyfill/10-misc.js
@@ -0,0 +1,61 @@
+/**
+ * @file
+ * @copyright 2020 Aleksej Komarov
+ * @license MIT
+ */
+
+/* eslint-disable */
+(function () {
+ 'use strict';
+
+ // Necessary polyfill to make Webpack code splitting work on IE8
+ if (!Function.prototype.bind) (function () {
+ var slice = Array.prototype.slice;
+ Function.prototype.bind = function () {
+ var thatFunc = this, thatArg = arguments[0];
+ var args = slice.call(arguments, 1);
+ if (typeof thatFunc !== 'function') {
+ // closest thing possible to the ECMAScript 5
+ // internal IsCallable function
+ throw new TypeError('Function.prototype.bind - ' +
+ 'what is trying to be bound is not callable');
+ }
+ return function () {
+ var funcArgs = args.concat(slice.call(arguments))
+ return thatFunc.apply(thatArg, funcArgs);
+ };
+ };
+ })();
+
+ if (!Array.prototype['forEach']) {
+ Array.prototype.forEach = function (callback, thisArg) {
+ if (this == null) {
+ throw new TypeError('Array.prototype.forEach called on null or undefined');
+ }
+ var T, k;
+ var O = Object(this);
+ var len = O.length >>> 0;
+ if (typeof callback !== "function") {
+ throw new TypeError(callback + ' is not a function');
+ }
+ if (arguments.length > 1) {
+ T = thisArg;
+ }
+ k = 0;
+ while (k < len) {
+ var kValue;
+ if (k in O) {
+ kValue = O[k];
+ callback.call(T, kValue, k, O);
+ }
+ k++;
+ }
+ };
+ }
+
+ // Inferno needs Int32Array, and it is not covered by core-js.
+ if (!window.Int32Array) {
+ window.Int32Array = Array;
+ }
+
+})();
diff --git a/tgui/packages/tgui-polyfill/index.js b/tgui/packages/tgui-polyfill/index.js
index c7a1e57ad4f9..7c45386d8b4f 100644
--- a/tgui/packages/tgui-polyfill/index.js
+++ b/tgui/packages/tgui-polyfill/index.js
@@ -4,18 +4,12 @@
* @license MIT
*/
+// NOTE: There are numbered polyfills, which are baked and injected directly
+// into `tgui.html`. See how they're baked in `package.json`.
+
import 'core-js/es';
import 'core-js/web/immediate';
import 'core-js/web/queue-microtask';
import 'core-js/web/timers';
import 'regenerator-runtime/runtime';
-import './html5shiv';
-import './ie8';
-import './dom4';
-import './css-om';
-import './inferno';
-
-// Fetch is required for Webpack HMR
-if (module.hot) {
- require('whatwg-fetch');
-}
+import 'unfetch/polyfill';
diff --git a/tgui/packages/tgui-polyfill/inferno.js b/tgui/packages/tgui-polyfill/inferno.js
deleted file mode 100644
index f9182cedb3e7..000000000000
--- a/tgui/packages/tgui-polyfill/inferno.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * @file
- * @copyright 2020 Aleksej Komarov
- * @license MIT
- */
-
-// Inferno needs Int32Array, and it is not covered by core-js.
-if (!window.Int32Array) {
- window.Int32Array = Array;
-}
diff --git a/tgui/packages/tgui-polyfill/package.json b/tgui/packages/tgui-polyfill/package.json
index 9c351877b7d3..e4d6cc70148b 100644
--- a/tgui/packages/tgui-polyfill/package.json
+++ b/tgui/packages/tgui-polyfill/package.json
@@ -2,9 +2,15 @@
"private": true,
"name": "tgui-polyfill",
"version": "4.3.0",
+ "scripts": {
+ "tgui-polyfill:build": "terser 00-html5shiv.js 01-ie8.js 02-dom4.js 03-css-om.js 10-misc.js --ie8 -f ascii_only,comments=false -o ../../public/tgui-polyfill.bundle.js"
+ },
"dependencies": {
"core-js": "^3.16.1",
"regenerator-runtime": "^0.13.9",
- "whatwg-fetch": "^3.6.2"
+ "unfetch": "^4.2.0"
+ },
+ "devDependencies": {
+ "terser": "^5.12.1"
}
}
diff --git a/tgui/packages/tgui/backend.ts b/tgui/packages/tgui/backend.ts
index 31f59651ff96..7ca6eeecde1c 100644
--- a/tgui/packages/tgui/backend.ts
+++ b/tgui/packages/tgui/backend.ts
@@ -134,19 +134,15 @@ export const backendMiddleware = store => {
}
if (type === 'ping') {
- sendMessage({
- type: 'pingReply',
- });
+ Byond.sendMessage('pingReply');
return;
}
if (type === 'backend/suspendStart' && !suspendInterval) {
- logger.log(`suspending (${window.__windowId__})`);
+ logger.log(`suspending (${Byond.windowId})`);
// Keep sending suspend messages until it succeeds.
// It may fail multiple times due to topic rate limiting.
- const suspendFn = () => sendMessage({
- type: 'suspend',
- });
+ const suspendFn = () => Byond.sendMessage('suspend');
suspendFn();
suspendInterval = setInterval(suspendFn, 2000);
}
@@ -155,7 +151,7 @@ export const backendMiddleware = store => {
suspendRenderer();
clearInterval(suspendInterval);
suspendInterval = undefined;
- Byond.winset(window.__windowId__, {
+ Byond.winset(Byond.windowId, {
'is-visible': false,
});
setImmediate(() => focusMap());
@@ -171,7 +167,7 @@ export const backendMiddleware = store => {
else if (fancyState !== fancy) {
logger.log('changing fancy mode to', fancy);
fancyState = fancy;
- Byond.winset(window.__windowId__, {
+ Byond.winset(Byond.windowId, {
titlebar: !fancy,
'can-resize': !fancy,
});
@@ -182,7 +178,6 @@ export const backendMiddleware = store => {
if (type === 'backend/update' && suspended) {
// Show the payload
logger.log('backend/update', payload);
- logger.log('update', payload);
// Signal renderer that we have resumed
resumeRenderer();
// Setup drag
@@ -196,7 +191,7 @@ export const backendMiddleware = store => {
if (suspended) {
return;
}
- Byond.winset(window.__windowId__, {
+ Byond.winset(Byond.windowId, {
'is-visible': true,
});
perf.mark('resume/finish');
@@ -211,25 +206,6 @@ export const backendMiddleware = store => {
};
};
-/**
- * Sends a message to /datum/tgui_window.
- */
-export const sendMessage = (message: any = {}) => {
- const { payload, ...rest } = message;
- const data: any = {
- // Message identifying header
- tgui: 1,
- window_id: window.__windowId__,
- // Message body
- ...rest,
- };
- // JSON-encode the payload
- if (payload !== null && payload !== undefined) {
- data.payload = JSON.stringify(payload);
- }
- Byond.topic(data);
-};
-
/**
* Sends an action to `ui_act` on `src_object` that this tgui window
* is associated with.
@@ -243,10 +219,7 @@ export const sendAct = (action: string, payload: object = {}) => {
logger.error(`Payload for act() must be an object, got this:`, payload);
return;
}
- sendMessage({
- type: 'act/' + action,
- payload,
- });
+ Byond.sendMessage('act/' + action, payload);
};
type BackendState = {
@@ -254,6 +227,7 @@ type BackendState = {
title: string,
status: number,
interface: string,
+ refreshing: boolean,
window: {
key: string,
size: [number, number],
@@ -372,7 +346,7 @@ export const useSharedState = (
return [
sharedState,
nextState => {
- sendMessage({
+ Byond.sendMessage({
type: 'setSharedState',
key,
value: JSON.stringify(
diff --git a/tgui/packages/tgui/components/AnimatedNumber.js b/tgui/packages/tgui/components/AnimatedNumber.js
deleted file mode 100644
index 05b89c96d022..000000000000
--- a/tgui/packages/tgui/components/AnimatedNumber.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @file
- * @copyright 2020 Aleksej Komarov
- * @license MIT
- */
-
-import { clamp, toFixed } from 'common/math';
-import { Component } from 'inferno';
-
-const FPS = 20;
-const Q = 0.5;
-
-const isSafeNumber = value => {
- return typeof value === 'number'
- && Number.isFinite(value)
- && !Number.isNaN(value);
-};
-
-export class AnimatedNumber extends Component {
- constructor(props) {
- super(props);
- this.timer = null;
- this.state = {
- value: 0,
- };
- // Use provided initial state
- if (isSafeNumber(props.initial)) {
- this.state.value = props.initial;
- }
- // Set initial state with value provided in props
- else if (isSafeNumber(props.value)) {
- this.state.value = Number(props.value);
- }
- }
-
- tick() {
- const { props, state } = this;
- const currentValue = Number(state.value);
- const targetValue = Number(props.value);
- // Avoid poisoning our state with infinities and NaN
- if (!isSafeNumber(targetValue)) {
- return;
- }
- // Smooth the value using an exponential moving average
- const value = currentValue * Q + targetValue * (1 - Q);
- this.setState({ value });
- }
-
- componentDidMount() {
- this.timer = setInterval(() => this.tick(), 1000 / FPS);
- }
-
- componentWillUnmount() {
- clearTimeout(this.timer);
- }
-
- render() {
- const { props, state } = this;
- const { format, children } = props;
- const currentValue = state.value;
- const targetValue = props.value;
- // Directly display values which can't be animated
- if (!isSafeNumber(targetValue)) {
- return targetValue || null;
- }
- let formattedValue;
- // Use custom formatter
- if (format) {
- formattedValue = format(currentValue);
- }
- // Fix our animated precision at target value's precision.
- else {
- const fraction = String(targetValue).split('.')[1];
- const precision = fraction ? fraction.length : 0;
- formattedValue = toFixed(currentValue, clamp(precision, 0, 8));
- }
- // Use a custom render function
- if (typeof children === 'function') {
- return children(formattedValue, currentValue);
- }
- return formattedValue;
- }
-}
diff --git a/tgui/packages/tgui/components/Autofocus.tsx b/tgui/packages/tgui/components/Autofocus.tsx
index 28945dd7aa48..f5ae00650aab 100644
--- a/tgui/packages/tgui/components/Autofocus.tsx
+++ b/tgui/packages/tgui/components/Autofocus.tsx
@@ -1,4 +1,4 @@
-import { Component, createRef } from 'inferno';
+import { Component, createRef } from "inferno";
export class Autofocus extends Component {
ref = createRef();
diff --git a/tgui/packages/tgui/components/Blink.js b/tgui/packages/tgui/components/Blink.js
index bd781336b463..1f1fd908318d 100644
--- a/tgui/packages/tgui/components/Blink.js
+++ b/tgui/packages/tgui/components/Blink.js
@@ -42,10 +42,8 @@ export class Blink extends Component {
}
componentDidUpdate(prevProps) {
- if (
- prevProps.interval !== this.props.interval ||
- prevProps.time !== this.props.time
- ) {
+ if (prevProps.interval !== this.props.interval
+ || prevProps.time !== this.props.time) {
this.createTimer();
}
}
@@ -57,10 +55,9 @@ export class Blink extends Component {
render(props) {
return (
-
+
{props.children}
);
diff --git a/tgui/packages/tgui/components/BlockQuote.js b/tgui/packages/tgui/components/BlockQuote.js
index 8c0f53a46a1e..62a052157288 100644
--- a/tgui/packages/tgui/components/BlockQuote.js
+++ b/tgui/packages/tgui/components/BlockQuote.js
@@ -7,7 +7,14 @@
import { classes } from 'common/react';
import { Box } from './Box';
-export const BlockQuote = (props) => {
+export const BlockQuote = props => {
const { className, ...rest } = props;
- return ;
+ return (
+
+ );
};
diff --git a/tgui/packages/tgui/components/BodyZoneSelector.tsx b/tgui/packages/tgui/components/BodyZoneSelector.tsx
index c9248bf9095f..6e3d2ecbd8b0 100644
--- a/tgui/packages/tgui/components/BodyZoneSelector.tsx
+++ b/tgui/packages/tgui/components/BodyZoneSelector.tsx
@@ -1,92 +1,90 @@
-import { Component, createRef } from 'inferno';
-import { resolveAsset } from '../assets';
-import { Box } from './Box';
+import { Component, createRef } from "inferno";
+import { resolveAsset } from "../assets";
+import { Box } from "./Box";
export enum BodyZone {
- Head = 'head',
- Chest = 'chest',
- LeftArm = 'l_arm',
- RightArm = 'r_arm',
- LeftLeg = 'l_leg',
- RightLeg = 'r_leg',
- Eyes = 'eyes',
- Mouth = 'mouth',
- Groin = 'groin',
+ Head = "head",
+ Chest = "chest",
+ LeftArm = "l_arm",
+ RightArm = "r_arm",
+ LeftLeg = "l_leg",
+ RightLeg = "r_leg",
+ Eyes = "eyes",
+ Mouth = "mouth",
+ Groin = "groin",
}
-const bodyZonePixelToZone = (x: number, y: number): BodyZone | null => {
- // TypeScript translation of /atom/movable/screen/zone_sel/proc/get_zone_at
- if (y < 1) {
- return null;
- } else if (y < 10) {
- if (x > 10 && x < 15) {
- return BodyZone.RightLeg;
- } else if (x > 17 && x < 22) {
- return BodyZone.LeftLeg;
- }
- } else if (y < 13) {
- if (x > 8 && x < 11) {
- return BodyZone.RightArm;
- } else if (x > 12 && x < 20) {
- return BodyZone.Groin;
- } else if (x > 21 && x < 24) {
- return BodyZone.LeftArm;
- }
- } else if (y < 22) {
- if (x > 8 && x < 11) {
- return BodyZone.RightArm;
- } else if (x > 12 && x < 20) {
- return BodyZone.Chest;
- } else if (x > 21 && x < 24) {
- return BodyZone.LeftArm;
+const bodyZonePixelToZone: (x: number, y: number) => (BodyZone | null)
+ = (x, y) => {
+ // TypeScript translation of /atom/movable/screen/zone_sel/proc/get_zone_at
+ if (y < 1) {
+ return null;
+ } else if (y < 10) {
+ if (x > 10 && x < 15) {
+ return BodyZone.RightLeg;
+ } else if (x > 17 && x < 22) {
+ return BodyZone.LeftLeg;
+ }
+ } else if (y < 13) {
+ if (x > 8 && x < 11) {
+ return BodyZone.RightArm;
+ } else if (x > 12 && x < 20) {
+ return BodyZone.Groin;
+ } else if (x > 21 && x < 24) {
+ return BodyZone.LeftArm;
+ }
+ } else if (y < 22) {
+ if (x > 8 && x < 11) {
+ return BodyZone.RightArm;
+ } else if (x > 12 && x < 20) {
+ return BodyZone.Chest;
+ } else if (x > 21 && x < 24) {
+ return BodyZone.LeftArm;
+ }
+ } else if (y < 30 && (x > 12 && x < 20)) {
+ if (y > 23 && y < 24 && (x > 15 && x < 17)) {
+ return BodyZone.Mouth;
+ } else if (y > 25 && y < 27 && (x > 14 && x < 18)) {
+ return BodyZone.Eyes;
+ } else {
+ return BodyZone.Head;
+ }
}
- } else if (y < 30 && x > 12 && x < 20) {
- if (y > 23 && y < 24 && x > 15 && x < 17) {
- return BodyZone.Mouth;
- } else if (y > 25 && y < 27 && x > 14 && x < 18) {
- return BodyZone.Eyes;
- } else {
- return BodyZone.Head;
- }
- }
- return null;
-};
+ return null;
+ };
type BodyZoneSelectorProps = {
- onClick?: (zone: BodyZone) => void;
- scale?: number;
- selectedZone: BodyZone | null;
-};
+ onClick?: (zone: BodyZone) => void,
+ scale?: number,
+ selectedZone: BodyZone | null,
+}
type BodyZoneSelectorState = {
- hoverZone: BodyZone | null;
-};
+ hoverZone: BodyZone | null,
+}
-export class BodyZoneSelector extends Component<
- BodyZoneSelectorProps,
- BodyZoneSelectorState
-> {
+export class BodyZoneSelector
+ extends Component
+{
ref = createRef();
state: BodyZoneSelectorState = {
hoverZone: null,
- };
+ }
render() {
const { hoverZone } = this.state;
const { scale = 3, selectedZone } = this.props;
return (
-
+
{
const onClick = this.props.onClick;
if (onClick && this.state.hoverZone) {
@@ -104,17 +102,17 @@ export class BodyZoneSelector extends Component<
}
const x = event.clientX - rect.left;
- const y = 32 * scale - (event.clientY - rect.top);
+ const y = (32 * scale) - (event.clientY - rect.top);
this.setState({
hoverZone: bodyZonePixelToZone(x / scale, y / scale),
});
}}
style={{
- '-ms-interpolation-mode': 'nearest-neighbor',
- 'position': 'absolute',
- 'width': `${32 * scale}px`,
- 'height': `${32 * scale}px`,
+ "-ms-interpolation-mode": "nearest-neighbor",
+ "position": "absolute",
+ "width": `${32 * scale}px`,
+ "height": `${32 * scale}px`,
}}
/>
@@ -123,26 +121,26 @@ export class BodyZoneSelector extends Component<
as="img"
src={resolveAsset(`body_zones.${selectedZone}.png`)}
style={{
- '-ms-interpolation-mode': 'nearest-neighbor',
- 'pointer-events': 'none',
- 'position': 'absolute',
- 'width': `${32 * scale}px`,
- 'height': `${32 * scale}px`,
+ "-ms-interpolation-mode": "nearest-neighbor",
+ "pointer-events": "none",
+ "position": "absolute",
+ "width": `${32 * scale}px`,
+ "height": `${32 * scale}px`,
}}
/>
)}
- {hoverZone && hoverZone !== selectedZone && (
+ {hoverZone && (hoverZone !== selectedZone) && (
)}
diff --git a/tgui/packages/tgui/components/Box.tsx b/tgui/packages/tgui/components/Box.tsx
index ed10072b6239..188d9b8c81f8 100644
--- a/tgui/packages/tgui/components/Box.tsx
+++ b/tgui/packages/tgui/components/Box.tsx
@@ -9,7 +9,7 @@ import { createVNode, InfernoNode, SFC } from 'inferno';
import { ChildFlags, VNodeFlags } from 'inferno-vnode-flags';
import { CSS_COLORS } from '../constants';
-export type BoxProps = {
+export interface BoxProps {
[key: string]: any;
as?: string;
className?: string | BooleanLike;
@@ -57,7 +57,7 @@ export type BoxProps = {
textColor?: string | BooleanLike;
backgroundColor?: string | BooleanLike;
fillPositionedParent?: boolean;
-};
+}
/**
* Coverts our rem-like spacing unit into a CSS unit.
@@ -93,10 +93,10 @@ export const halfUnit = (value: unknown): string | undefined => {
const isColorCode = (str: unknown) => !isColorClass(str);
const isColorClass = (str: unknown): boolean => {
- return typeof str === 'string' && CSS_COLORS.includes(str);
+ return typeof str === "string" && CSS_COLORS.includes(str);
};
-const mapRawPropTo = (attrName) => (style, value) => {
+const mapRawPropTo = attrName => (style, value) => {
if (typeof value === 'number' || typeof value === 'string') {
style[attrName] = value;
}
@@ -122,7 +122,7 @@ const mapDirectionalUnitPropTo = (attrName, unit, dirs) => (style, value) => {
}
};
-const mapColorPropTo = (attrName) => (style, value) => {
+const mapColorPropTo = attrName => (style, value) => {
if (isColorCode(value)) {
style[attrName] = value;
}
@@ -149,7 +149,8 @@ const styleMapperByPropName = {
lineHeight: (style, value) => {
if (typeof value === 'number') {
style['line-height'] = value;
- } else if (typeof value === 'string') {
+ }
+ else if (typeof value === 'string') {
style['line-height'] = unit(value);
}
},
@@ -162,28 +163,30 @@ const styleMapperByPropName = {
italic: mapBooleanPropTo('font-style', 'italic'),
nowrap: mapBooleanPropTo('white-space', 'nowrap'),
preserveWhitespace: mapBooleanPropTo('white-space', 'pre-wrap'),
- // Margin
+ // Margins
m: mapDirectionalUnitPropTo('margin', halfUnit, [
- 'top',
- 'bottom',
- 'left',
- 'right',
+ 'top', 'bottom', 'left', 'right',
+ ]),
+ mx: mapDirectionalUnitPropTo('margin', halfUnit, [
+ 'left', 'right',
+ ]),
+ my: mapDirectionalUnitPropTo('margin', halfUnit, [
+ 'top', 'bottom',
]),
- mx: mapDirectionalUnitPropTo('margin', halfUnit, ['left', 'right']),
- my: mapDirectionalUnitPropTo('margin', halfUnit, ['top', 'bottom']),
mt: mapUnitPropTo('margin-top', halfUnit),
mb: mapUnitPropTo('margin-bottom', halfUnit),
ml: mapUnitPropTo('margin-left', halfUnit),
mr: mapUnitPropTo('margin-right', halfUnit),
- // Padding
+ // Margins
p: mapDirectionalUnitPropTo('padding', halfUnit, [
- 'top',
- 'bottom',
- 'left',
- 'right',
+ 'top', 'bottom', 'left', 'right',
+ ]),
+ px: mapDirectionalUnitPropTo('padding', halfUnit, [
+ 'left', 'right',
+ ]),
+ py: mapDirectionalUnitPropTo('padding', halfUnit, [
+ 'top', 'bottom',
]),
- px: mapDirectionalUnitPropTo('padding', halfUnit, ['left', 'right']),
- py: mapDirectionalUnitPropTo('padding', halfUnit, ['top', 'bottom']),
pt: mapUnitPropTo('padding-top', halfUnit),
pb: mapUnitPropTo('padding-bottom', halfUnit),
pl: mapUnitPropTo('padding-left', halfUnit),
@@ -221,7 +224,8 @@ export const computeBoxProps = (props: BoxProps) => {
const mapPropToStyle = styleMapperByPropName[propName];
if (mapPropToStyle) {
mapPropToStyle(computedStyles, propValue);
- } else {
+ }
+ else {
computedProps[propName] = propValue;
}
}
@@ -253,15 +257,19 @@ export const computeBoxClassName = (props: BoxProps) => {
};
export const Box: SFC = (props: BoxProps) => {
- const { as = 'div', className, children, ...rest } = props;
+ const {
+ as = 'div',
+ className,
+ children,
+ ...rest
+ } = props;
// Render props
if (typeof children === 'function') {
return children(computeBoxProps(props));
}
- const computedClassName =
- typeof className === 'string'
- ? className + ' ' + computeBoxClassName(rest)
- : computeBoxClassName(rest);
+ const computedClassName = typeof className === 'string'
+ ? className + ' ' + computeBoxClassName(rest)
+ : computeBoxClassName(rest);
const computedProps = computeBoxProps(rest);
// Render a wrapper element
return createVNode(
@@ -271,7 +279,7 @@ export const Box: SFC = (props: BoxProps) => {
children,
ChildFlags.UnknownChildren,
computedProps,
- undefined
+ undefined,
);
};
diff --git a/tgui/packages/tgui/components/Button.js b/tgui/packages/tgui/components/Button.js
index ef434d04c7aa..ee2fa1ac174c 100644
--- a/tgui/packages/tgui/components/Button.js
+++ b/tgui/packages/tgui/components/Button.js
@@ -14,7 +14,7 @@ import { Tooltip } from './Tooltip';
const logger = createLogger('Button');
-export const Button = (props) => {
+export const Button = props => {
const {
className,
fluid,
@@ -42,13 +42,12 @@ export const Button = (props) => {
// A warning about the lowercase onclick
if (onclick) {
logger.warn(
- `Lowercase 'onclick' is not supported on Button and lowercase` +
- ` prop names are discouraged in general. Please use a camelCase` +
- `'onClick' instead and read: ` +
- `https://infernojs.org/docs/guides/event-handling`
- );
+ `Lowercase 'onclick' is not supported on Button and lowercase`
+ + ` prop names are discouraged in general. Please use a camelCase`
+ + `'onClick' instead and read: `
+ + `https://infernojs.org/docs/guides/event-handling`);
}
- rest.onClick = (e) => {
+ rest.onClick = e => {
if (!disabled && onClick) {
onClick(e);
}
@@ -69,18 +68,17 @@ export const Button = (props) => {
circular && 'Button--circular',
compact && 'Button--compact',
iconPosition && 'Button--iconPosition--' + iconPosition,
- verticalAlignContent && 'Button--flex',
- verticalAlignContent && fluid && 'Button--flex--fluid',
- verticalAlignContent &&
- 'Button--verticalAlignContent--' + verticalAlignContent,
- color && typeof color === 'string'
+ verticalAlignContent && "Button--flex",
+ (verticalAlignContent && fluid) && "Button--flex--fluid",
+ verticalAlignContent && 'Button--verticalAlignContent--' + verticalAlignContent,
+ (color && typeof color === 'string')
? 'Button--color--' + color
: 'Button--color--default',
className,
computeBoxClassName(rest),
])}
tabIndex={!disabled && '0'}
- onKeyDown={(e) => {
+ onKeyDown={e => {
if (props.captureKeys === false) {
return;
}
@@ -136,15 +134,14 @@ export const Button = (props) => {
Button.defaultHooks = pureComponentHooks;
-export const ButtonCheckbox = (props) => {
+export const ButtonCheckbox = props => {
const { checked, ...rest } = props;
return (
+ {...rest} />
);
};
@@ -169,15 +166,16 @@ export class ButtonConfirm extends Component {
});
if (clickedOnce) {
setTimeout(() => window.addEventListener('click', this.handleClick));
- } else {
+ }
+ else {
window.removeEventListener('click', this.handleClick);
}
}
render() {
const {
- confirmContent = 'Confirm?',
- confirmColor = 'bad',
+ confirmContent = "Confirm?",
+ confirmColor = "bad",
confirmIcon,
icon,
color,
@@ -190,9 +188,9 @@ export class ButtonConfirm extends Component {
content={this.state.clickedOnce ? confirmContent : content}
icon={this.state.clickedOnce ? confirmIcon : icon}
color={this.state.clickedOnce ? confirmColor : color}
- onClick={() =>
- this.state.clickedOnce ? onClick() : this.setClickedOnce(true)
- }
+ onClick={() => this.state.clickedOnce
+ ? onClick()
+ : this.setClickedOnce(true)}
{...rest}
/>
);
@@ -217,11 +215,12 @@ export class ButtonInput extends Component {
if (this.inputRef) {
const input = this.inputRef.current;
if (inInput) {
- input.value = this.props.currentValue || '';
+ input.value = this.props.currentValue || "";
try {
input.focus();
input.select();
- } catch {}
+ }
+ catch {}
}
}
}
@@ -229,7 +228,7 @@ export class ButtonInput extends Component {
commitResult(e) {
if (this.inputRef) {
const input = this.inputRef.current;
- const hasValue = input.value !== '';
+ const hasValue = (input.value !== "");
if (hasValue) {
this.props.onCommit(e, input.value);
return;
@@ -266,8 +265,12 @@ export class ButtonInput extends Component {
])}
{...rest}
onClick={() => this.setInInput(true)}>
- {icon && }
- {content}
+ {icon && (
+
+ )}
+
+ {content}
+
{
+ onBlur={e => {
if (!this.state.inInput) {
return;
}
this.setInInput(false);
this.commitResult(e);
}}
- onKeyDown={(e) => {
+ onKeyDown={e => {
if (e.keyCode === KEY_ENTER) {
this.setInInput(false);
this.commitResult(e);
@@ -298,7 +301,10 @@ export class ButtonInput extends Component {
if (tooltip) {
buttonContent = (
-
+
{buttonContent}
);
@@ -309,55 +315,3 @@ export class ButtonInput extends Component {
}
Button.Input = ButtonInput;
-
-export class ButtonFile extends Component {
- constructor() {
- super();
- this.inputRef = createRef();
- }
-
- async read(files) {
- const promises = Array.from(files).map((file) => {
- let reader = new FileReader();
- return new Promise((resolve) => {
- reader.onload = () => resolve(reader.result);
- reader.readAsText(file);
- });
- });
-
- return await Promise.all(promises);
- }
-
- render() {
- const { onSelectFiles, accept, multiple, ...rest } = this.props;
- const filePicker = (
- {
- const files = this.inputRef.current.files;
- if (files.length) {
- const readFiles = await this.read(files);
- onSelectFiles(multiple ? readFiles : readFiles[0]);
- }
- }}
- />
- );
- return (
- <>
- {
- this.inputRef.current.click();
- }}
- />
- {filePicker}
- >
- );
- }
-}
-
-Button.File = ButtonFile;
diff --git a/tgui/packages/tgui/components/ByondUi.js b/tgui/packages/tgui/components/ByondUi.js
index a1f7e9104093..5b4f04dd0e5b 100644
--- a/tgui/packages/tgui/components/ByondUi.js
+++ b/tgui/packages/tgui/components/ByondUi.js
@@ -54,18 +54,19 @@ window.addEventListener('beforeunload', () => {
});
/**
- * Get the bounding box of the DOM element.
+ * Get the bounding box of the DOM element in display-pixels.
*/
const getBoundingBox = element => {
+ const pixelRatio = window.devicePixelRatio ?? 1;
const rect = element.getBoundingClientRect();
return {
pos: [
- rect.left,
- rect.top,
+ rect.left * pixelRatio,
+ rect.top * pixelRatio,
],
size: [
- rect.right - rect.left,
- rect.bottom - rect.top,
+ (rect.right - rect.left) * pixelRatio,
+ (rect.bottom - rect.top) * pixelRatio,
],
};
};
@@ -114,7 +115,7 @@ export class ByondUi extends Component {
const box = getBoundingBox(this.containerRef.current);
logger.debug('bounding box', box);
this.byondUiElement.render({
- parent: window.__windowId__,
+ parent: Byond.windowId,
...params,
pos: box.pos[0] + ',' + box.pos[1],
size: box.size[0] + 'x' + box.size[1],
diff --git a/tgui/packages/tgui/components/Chart.js b/tgui/packages/tgui/components/Chart.js
index fac444bd1da9..77913779db81 100644
--- a/tgui/packages/tgui/components/Chart.js
+++ b/tgui/packages/tgui/components/Chart.js
@@ -23,15 +23,15 @@ const normalizeData = (data, scale, rangeX, rangeY) => {
min[1] = rangeY[0];
max[1] = rangeY[1];
}
- const normalized = map((point) => {
+ const normalized = map(point => {
return zipWith((value, min, max, scale) => {
- return ((value - min) / (max - min)) * scale;
+ return (value - min) / (max - min) * scale;
})(point, min, max, scale);
})(data);
return normalized;
};
-const dataToPolylinePoints = (data) => {
+const dataToPolylinePoints = data => {
let points = '';
for (let i = 0; i < data.length; i++) {
const point = data[i];
@@ -89,7 +89,7 @@ class LineChart extends Component {
const points = dataToPolylinePoints(normalized);
return (
- {(props) => (
+ {props => (
+ points={points} />
)}
@@ -119,7 +118,7 @@ class LineChart extends Component {
LineChart.defaultHooks = pureComponentHooks;
-const Stub = (props) => null;
+const Stub = props => null;
// IE8: No inline svg support
export const Chart = {
diff --git a/tgui/packages/tgui/components/Collapsible.js b/tgui/packages/tgui/components/Collapsible.js
index f91eeddb4568..8b915814ba41 100644
--- a/tgui/packages/tgui/components/Collapsible.js
+++ b/tgui/packages/tgui/components/Collapsible.js
@@ -20,7 +20,13 @@ export class Collapsible extends Component {
render() {
const { props } = this;
const { open } = this.state;
- const { children, color = 'default', title, buttons, ...rest } = props;
+ const {
+ children,
+ color = 'default',
+ title,
+ buttons,
+ ...rest
+ } = props;
return (
@@ -35,10 +41,16 @@ export class Collapsible extends Component {
{buttons && (
- {buttons}
+
+ {buttons}
+
)}
- {open &&
{children} }
+ {open && (
+
+ {children}
+
+ )}
);
}
diff --git a/tgui/packages/tgui/components/ColorBox.js b/tgui/packages/tgui/components/ColorBox.js
index a6203ca4694c..10306cf4654c 100644
--- a/tgui/packages/tgui/components/ColorBox.js
+++ b/tgui/packages/tgui/components/ColorBox.js
@@ -7,8 +7,7 @@
import { classes, pureComponentHooks } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
-export const ColorBox = (props) => {
- // prettier-ignore
+export const ColorBox = props => {
const {
content,
children,
@@ -21,7 +20,11 @@ export const ColorBox = (props) => {
rest.backgroundColor = color || backgroundColor;
return (
{content || '.'}
diff --git a/tgui/packages/tgui/components/Dimmer.js b/tgui/packages/tgui/components/Dimmer.js
index 85e046ca0dbd..d97c3626a3eb 100644
--- a/tgui/packages/tgui/components/Dimmer.js
+++ b/tgui/packages/tgui/components/Dimmer.js
@@ -7,11 +7,18 @@
import { classes } from 'common/react';
import { Box } from './Box';
-export const Dimmer = (props) => {
+export const Dimmer = props => {
const { className, children, ...rest } = props;
return (
-
- {children}
+
+
+ {children}
+
);
};
diff --git a/tgui/packages/tgui/components/Divider.js b/tgui/packages/tgui/components/Divider.js
index 8cbf9b77d7d0..b5338428263e 100644
--- a/tgui/packages/tgui/components/Divider.js
+++ b/tgui/packages/tgui/components/Divider.js
@@ -6,15 +6,19 @@
import { classes } from 'common/react';
-export const Divider = (props) => {
- const { vertical, hidden } = props;
+export const Divider = props => {
+ const {
+ vertical,
+ hidden,
+ } = props;
return (
+ vertical
+ ? 'Divider--vertical'
+ : 'Divider--horizontal',
+ ])} />
);
};
diff --git a/tgui/packages/tgui/components/Dropdown.js b/tgui/packages/tgui/components/Dropdown.js
index e6fe8a840a5f..a02f0715ab7c 100644
--- a/tgui/packages/tgui/components/Dropdown.js
+++ b/tgui/packages/tgui/components/Dropdown.js
@@ -30,11 +30,10 @@ export class Dropdown extends Component {
setOpen(open) {
this.setState({ open: open });
if (open) {
- setTimeout(() => {
- window.addEventListener('click', this.handleClick);
- });
+ setTimeout(() => window.addEventListener('click', this.handleClick));
this.menuRef.focus();
- } else {
+ }
+ else {
window.removeEventListener('click', this.handleClick);
}
}
@@ -49,10 +48,10 @@ export class Dropdown extends Component {
buildMenu() {
const { options = [] } = this.props;
- const ops = options.map((option) => {
+ const ops = options.map(option => {
let displayText, value;
- if (typeof option === 'string') {
+ if (typeof option === "string") {
displayText = option;
value = option;
} else {
@@ -95,21 +94,22 @@ export class Dropdown extends Component {
displayText,
...boxProps
} = props;
- const { className, ...rest } = boxProps;
+ const {
+ className,
+ ...rest
+ } = boxProps;
const adjustedOpen = over ? !this.state.open : this.state.open;
const menu = this.state.open ? (
{
- this.menuRef = menu;
- }}
+ ref={menu => { this.menuRef = menu; }}
tabIndex="-1"
style={{
'width': openWidth,
}}
className={classes([
- (noscroll && 'Dropdown__menu-noscroll') || 'Dropdown__menu',
+ noscroll && 'Dropdown__menu-noscroll' || 'Dropdown__menu',
over && 'Dropdown__over',
])}>
{this.buildMenu()}
@@ -139,13 +139,15 @@ export class Dropdown extends Component {
}
}}>
{icon && (
-
+
)}
-
+
{displayText ? displayText : this.state.selected}
{!!nochevron || (
diff --git a/tgui/packages/tgui/components/FitText.tsx b/tgui/packages/tgui/components/FitText.tsx
index 334eb970733f..445e59119c99 100644
--- a/tgui/packages/tgui/components/FitText.tsx
+++ b/tgui/packages/tgui/components/FitText.tsx
@@ -1,30 +1,26 @@
-import { Component, createRef, RefObject } from 'inferno';
+import { Component, createRef, RefObject } from "inferno";
const DEFAULT_ACCEPTABLE_DIFFERENCE = 5;
-type Props = {
- acceptableDifference?: number;
- maxWidth: number;
- maxFontSize: number;
- native?: HTMLAttributes;
-};
-
-type State = {
- fontSize: number;
-};
-
-export class FitText extends Component {
+export class FitText extends Component<{
+ acceptableDifference?: number,
+ maxWidth: number,
+ maxFontSize: number,
+ native?: HTMLAttributes,
+}, {
+ fontSize: number,
+}> {
ref: RefObject = createRef();
- state: State = {
+ state = {
fontSize: 0,
- };
+ }
constructor() {
super();
this.resize = this.resize.bind(this);
- window.addEventListener('resize', this.resize);
+ window.addEventListener("resize", this.resize);
}
componentDidUpdate(prevProps) {
@@ -34,7 +30,7 @@ export class FitText extends Component {
}
componentWillUnmount() {
- window.removeEventListener('resize', this.resize);
+ window.removeEventListener("resize", this.resize);
}
resize() {
@@ -57,8 +53,8 @@ export class FitText extends Component {
if (difference > 0) {
end = middle;
} else if (
- difference <
- (this.props.acceptableDifference ?? DEFAULT_ACCEPTABLE_DIFFERENCE)
+ difference
+ < (this.props.acceptableDifference ?? DEFAULT_ACCEPTABLE_DIFFERENCE)
) {
start = middle;
} else {
@@ -80,9 +76,11 @@ export class FitText extends Component {
{this.props.children}
diff --git a/tgui/packages/tgui/components/Flex.tsx b/tgui/packages/tgui/components/Flex.tsx
index f67738280bac..ec5e4ebfce85 100644
--- a/tgui/packages/tgui/components/Flex.tsx
+++ b/tgui/packages/tgui/components/Flex.tsx
@@ -26,7 +26,15 @@ export const computeFlexClassName = (props: FlexProps) => {
};
export const computeFlexProps = (props: FlexProps) => {
- const { className, direction, wrap, align, justify, inline, ...rest } = props;
+ const {
+ className,
+ direction,
+ wrap,
+ align,
+ justify,
+ inline,
+ ...rest
+ } = props;
return computeBoxProps({
style: {
...rest.style,
@@ -39,11 +47,14 @@ export const computeFlexProps = (props: FlexProps) => {
});
};
-export const Flex = (props) => {
+export const Flex = props => {
const { className, ...rest } = props;
return (
);
@@ -68,7 +79,6 @@ export const computeFlexItemClassName = (props: FlexItemProps) => {
};
export const computeFlexItemProps = (props: FlexItemProps) => {
- // prettier-ignore
const {
className,
style,
@@ -79,7 +89,6 @@ export const computeFlexItemProps = (props: FlexItemProps) => {
align,
...rest
} = props;
- // prettier-ignore
const computedBasis = basis
// IE11: Set basis to specified width if it's known, which fixes certain
// bugs when rendering tables inside the flex.
@@ -100,11 +109,14 @@ export const computeFlexItemProps = (props: FlexItemProps) => {
});
};
-const FlexItem = (props) => {
+const FlexItem = props => {
const { className, ...rest } = props;
return (
);
diff --git a/tgui/packages/tgui/components/Grid.js b/tgui/packages/tgui/components/Grid.js
index 7ad3fd1dfdbf..3269b672006c 100644
--- a/tgui/packages/tgui/components/Grid.js
+++ b/tgui/packages/tgui/components/Grid.js
@@ -8,11 +8,13 @@ import { Table } from './Table';
import { pureComponentHooks } from 'common/react';
/** @deprecated */
-export const Grid = (props) => {
+export const Grid = props => {
const { children, ...rest } = props;
return (
- {children}
+
+ {children}
+
);
};
@@ -20,7 +22,7 @@ export const Grid = (props) => {
Grid.defaultHooks = pureComponentHooks;
/** @deprecated */
-export const GridColumn = (props) => {
+export const GridColumn = props => {
const { size = 1, style, ...rest } = props;
return (
{
width: size + '%',
...style,
}}
- {...rest}
- />
+ {...rest} />
);
};
diff --git a/tgui/packages/tgui/components/Icon.js b/tgui/packages/tgui/components/Icon.js
index e9a4d7fd7ba9..98a40bf5d941 100644
--- a/tgui/packages/tgui/components/Icon.js
+++ b/tgui/packages/tgui/components/Icon.js
@@ -11,14 +11,22 @@ import { computeBoxClassName, computeBoxProps } from './Box';
const FA_OUTLINE_REGEX = /-o$/;
-export const Icon = (props) => {
- const { name, size, spin, className, rotation, inverse, ...rest } = props;
+export const Icon = props => {
+ const {
+ name,
+ size,
+ spin,
+ className,
+ rotation,
+ inverse,
+ ...rest
+ } = props;
if (size) {
if (!rest.style) {
rest.style = {};
}
- rest.style['font-size'] = size * 100 + '%';
+ rest.style['font-size'] = (size * 100) + '%';
}
if (typeof rotation === 'number') {
if (!rest.style) {
@@ -29,18 +37,15 @@ export const Icon = (props) => {
const boxProps = computeBoxProps(rest);
- let iconClass = '';
- if (name.startsWith('tg-')) {
+ let iconClass = "";
+ if (name.startsWith("tg-")) {
// tgfont icon
iconClass = name;
} else {
// font awesome icon
const faRegular = FA_OUTLINE_REGEX.test(name);
const faName = name.replace(FA_OUTLINE_REGEX, '');
- // prettier-ignore
- iconClass = (faRegular ? 'far ' : 'fas ')
- + 'fa-' + faName
- + (spin ? ' fa-spin' : '');
+ iconClass = (faRegular ? 'far ' : 'fas ') + 'fa-'+ faName + (spin ? " fa-spin" : "");
}
return (
{
className,
computeBoxClassName(rest),
])}
- {...boxProps}
- />
+ {...boxProps} />
);
};
Icon.defaultHooks = pureComponentHooks;
-export const IconStack = (props) => {
- const { className, children, ...rest } = props;
+export const IconStack = props => {
+ const {
+ className,
+ children,
+ ...rest
+ } = props;
return (
{children}
diff --git a/tgui/packages/tgui/components/InfinitePlane.js b/tgui/packages/tgui/components/InfinitePlane.js
index 74f60f1e4d4c..393ac1de81d1 100644
--- a/tgui/packages/tgui/components/InfinitePlane.js
+++ b/tgui/packages/tgui/components/InfinitePlane.js
@@ -1,7 +1,7 @@
-import { computeBoxProps } from './Box';
-import { Stack } from './Stack';
-import { ProgressBar } from './ProgressBar';
-import { Button } from './Button';
+import { computeBoxProps } from "./Box";
+import { Stack } from "./Stack";
+import { ProgressBar } from "./ProgressBar";
+import { Button } from "./Button";
import { Component } from 'inferno';
const ZOOM_MIN_VAL = 0.5;
@@ -35,19 +35,19 @@ export class InfinitePlane extends Component {
}
componentDidMount() {
- window.addEventListener('mouseup', this.onMouseUp);
+ window.addEventListener("mouseup", this.onMouseUp);
- window.addEventListener('mousedown', this.doOffsetMouse);
- window.addEventListener('mousemove', this.doOffsetMouse);
- window.addEventListener('mouseup', this.doOffsetMouse);
+ window.addEventListener("mousedown", this.doOffsetMouse);
+ window.addEventListener("mousemove", this.doOffsetMouse);
+ window.addEventListener("mouseup", this.doOffsetMouse);
}
componentWillUnmount() {
- window.removeEventListener('mouseup', this.onMouseUp);
+ window.removeEventListener("mouseup", this.onMouseUp);
- window.removeEventListener('mousedown', this.doOffsetMouse);
- window.removeEventListener('mousemove', this.doOffsetMouse);
- window.removeEventListener('mouseup', this.doOffsetMouse);
+ window.removeEventListener("mousedown", this.doOffsetMouse);
+ window.removeEventListener("mousemove", this.doOffsetMouse);
+ window.removeEventListener("mouseup", this.doOffsetMouse);
}
doOffsetMouse(event) {
@@ -75,7 +75,7 @@ export class InfinitePlane extends Component {
handleZoomIncrease(event) {
const { onZoomChange } = this.props;
const { zoom } = this.state;
- const newZoomValue = Math.min(zoom + ZOOM_INCREMENT, ZOOM_MAX_VAL);
+ const newZoomValue = Math.min(zoom+ZOOM_INCREMENT, ZOOM_MAX_VAL);
this.setState({
zoom: newZoomValue,
});
@@ -87,7 +87,7 @@ export class InfinitePlane extends Component {
handleZoomDecrease(event) {
const { onZoomChange } = this.props;
const { zoom } = this.state;
- const newZoomValue = Math.max(zoom - ZOOM_INCREMENT, ZOOM_MIN_VAL);
+ const newZoomValue = Math.max(zoom-ZOOM_INCREMENT, ZOOM_MIN_VAL);
this.setState({
zoom: newZoomValue,
});
@@ -98,7 +98,11 @@ export class InfinitePlane extends Component {
}
handleMouseMove(event) {
- const { onBackgroundMoved, initialLeft = 0, initialTop = 0 } = this.props;
+ const {
+ onBackgroundMoved,
+ initialLeft = 0,
+ initialTop = 0,
+ } = this.props;
if (this.state.mouseDown) {
let newX, newY;
this.setState((state) => {
@@ -110,7 +114,7 @@ export class InfinitePlane extends Component {
};
});
if (onBackgroundMoved) {
- onBackgroundMoved(newX + initialLeft, newY + initialTop);
+ onBackgroundMoved(newX+initialLeft, newY+initialTop);
}
}
}
@@ -124,7 +128,11 @@ export class InfinitePlane extends Component {
initialTop = 0,
...rest
} = this.props;
- const { left, top, zoom } = this.state;
+ const {
+ left,
+ top,
+ zoom,
+ } = this.state;
const finalLeft = initialLeft + left;
const finalTop = initialTop + top;
@@ -136,50 +144,62 @@ export class InfinitePlane extends Component {
...rest,
style: {
...rest.style,
- overflow: 'hidden',
- position: 'relative',
+ overflow: "hidden",
+ position: "relative",
},
- })}>
+ })}
+ >
+ "position": "fixed",
+ "transform": `translate(${finalLeft}px, ${finalTop}px) scale(${zoom})`,
+ "transform-origin": "top left",
+ "height": "100%",
+ "width": "100%",
+ }}
+ >
{children}
-
+
-
+
+ maxValue={ZOOM_MAX_VAL}
+ >
{zoom}x
-
+
diff --git a/tgui/packages/tgui/components/Input.js b/tgui/packages/tgui/components/Input.js
index ac7ce6eef360..4996ed322347 100644
--- a/tgui/packages/tgui/components/Input.js
+++ b/tgui/packages/tgui/components/Input.js
@@ -4,12 +4,11 @@
* @license MIT
*/
-import { KEY_ENTER, KEY_ESCAPE } from 'common/keycodes';
import { classes } from 'common/react';
import { Component, createRef } from 'inferno';
import { Box } from './Box';
+import { KEY_ESCAPE, KEY_ENTER } from 'common/keycodes';
-// prettier-ignore
export const toInputValue = value => (
typeof value !== 'number' && typeof value !== 'string'
? ''
@@ -23,7 +22,7 @@ export class Input extends Component {
this.state = {
editing: false,
};
- this.handleInput = (e) => {
+ this.handleInput = e => {
const { editing } = this.state;
const { onInput } = this.props;
if (!editing) {
@@ -33,13 +32,13 @@ export class Input extends Component {
onInput(e, e.target.value);
}
};
- this.handleFocus = (e) => {
+ this.handleFocus = e => {
const { editing } = this.state;
if (!editing) {
this.setEditing(true);
}
};
- this.handleBlur = (e) => {
+ this.handleBlur = e => {
const { editing } = this.state;
const { onChange } = this.props;
if (editing) {
@@ -49,7 +48,7 @@ export class Input extends Component {
}
}
};
- this.handleKeyDown = (e) => {
+ this.handleKeyDown = e => {
const { onInput, onChange, onEnter } = this.props;
if (e.keyCode === KEY_ENTER) {
this.setEditing(false);
@@ -129,7 +128,12 @@ export class Input extends Component {
...boxProps
} = props;
// Box props
- const { className, fluid, monospace, ...rest } = boxProps;
+ const {
+ className,
+ fluid,
+ monospace,
+ ...rest
+ } = boxProps;
return (
- .
+
+ .
+
+ maxLength={maxLength} />
);
}
diff --git a/tgui/packages/tgui/components/KeyListener.tsx b/tgui/packages/tgui/components/KeyListener.tsx
new file mode 100644
index 000000000000..46b527653654
--- /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/Knob.js b/tgui/packages/tgui/components/Knob.js
index 1d40ff1ed231..175792471b3d 100644
--- a/tgui/packages/tgui/components/Knob.js
+++ b/tgui/packages/tgui/components/Knob.js
@@ -10,11 +10,13 @@ import { computeBoxClassName, computeBoxProps } from './Box';
import { DraggableControl } from './DraggableControl';
import { NumberInput } from './NumberInput';
-export const Knob = (props) => {
+export const Knob = props => {
// IE8: I don't want to support a yet another component on IE8.
// IE8: It also can't handle SVG.
if (Byond.IS_LTE_IE8) {
- return ;
+ return (
+
+ );
}
const {
// Draggable props (passthrough)
@@ -58,7 +60,7 @@ export const Knob = (props) => {
unit,
value,
}}>
- {(control) => {
+ {control => {
const {
dragging,
editing,
@@ -71,11 +73,14 @@ export const Knob = (props) => {
const scaledFillValue = scale(
fillValue ?? displayValue,
minValue,
- maxValue
- );
- const scaledDisplayValue = scale(displayValue, minValue, maxValue);
- const effectiveColor =
- color || keyOfMatchingRange(fillValue ?? value, ranges) || 'default';
+ maxValue);
+ const scaledDisplayValue = scale(
+ displayValue,
+ minValue,
+ maxValue);
+ const effectiveColor = color
+ || keyOfMatchingRange(fillValue ?? value, ranges)
+ || 'default';
const rotation = Math.min((scaledDisplayValue - 0.5) * 270, 225);
return (
{
{dragging && (
- {displayElement}
+
+ {displayElement}
+
)}
-
+
{
+ r="50" />
{inputElement}
diff --git a/tgui/packages/tgui/components/LabeledControls.js b/tgui/packages/tgui/components/LabeledControls.js
index eea3b2c29bd3..b4f069192c5f 100644
--- a/tgui/packages/tgui/components/LabeledControls.js
+++ b/tgui/packages/tgui/components/LabeledControls.js
@@ -6,8 +6,12 @@
import { Flex } from './Flex';
-export const LabeledControls = (props) => {
- const { children, wrap, ...rest } = props;
+export const LabeledControls = props => {
+ const {
+ children,
+ wrap,
+ ...rest
+ } = props;
return (
{
);
};
-const LabeledControlsItem = (props) => {
- const { label, children, mx = 1, ...rest } = props;
+const LabeledControlsItem = props => {
+ const {
+ label,
+ children,
+ mx = 1,
+ ...rest
+ } = props;
return (
{
justify="space-between"
{...rest}>
- {children}
- {label}
+
+ {children}
+
+
+ {label}
+
);
diff --git a/tgui/packages/tgui/components/LabeledList.tsx b/tgui/packages/tgui/components/LabeledList.tsx
index 283c0a2b9120..938dc9c1d0c5 100644
--- a/tgui/packages/tgui/components/LabeledList.tsx
+++ b/tgui/packages/tgui/components/LabeledList.tsx
@@ -15,7 +15,11 @@ type LabeledListProps = {
export const LabeledList = (props: LabeledListProps) => {
const { children } = props;
- return ;
+ return (
+
+ );
};
LabeledList.defaultHooks = pureComponentHooks;
@@ -24,12 +28,11 @@ type LabeledListItemProps = {
className?: string | BooleanLike;
label?: string | InfernoNode | BooleanLike;
labelColor?: string | BooleanLike;
- labelWrap?: boolean;
color?: string | BooleanLike;
textAlign?: string | BooleanLike;
- buttons?: InfernoNode;
+ buttons?: InfernoNode,
/** @deprecated */
- content?: any;
+ content?: any,
children?: InfernoNode;
verticalAlign?: string;
};
@@ -39,39 +42,46 @@ const LabeledListItem = (props: LabeledListItemProps) => {
className,
label,
labelColor = 'label',
- labelWrap,
color,
textAlign,
buttons,
content,
children,
- verticalAlign = 'baseline',
+ verticalAlign = "baseline",
} = props;
return (
-
+
- {label ? (typeof label === 'string' ? label + ':' : label) : null}
+ {label ? typeof(label) === "string" ? label + ':' : label : null}
{content}
{children}
{buttons && (
- {buttons}
+
+ {buttons}
+
)}
);
@@ -84,7 +94,9 @@ type LabeledListDividerProps = {
};
const LabeledListDivider = (props: LabeledListDividerProps) => {
- const padding = props.size ? unit(Math.max(0, props.size - 1)) : 0;
+ const padding = props.size
+ ? unit(Math.max(0, props.size - 1))
+ : 0;
return (
{
- const { className, children, ...rest } = props;
+export const Modal = props => {
+ const {
+ className,
+ children,
+ ...rest
+ } = props;
return (
{children}
diff --git a/tgui/packages/tgui/components/NoticeBox.js b/tgui/packages/tgui/components/NoticeBox.js
index 1c3b49b16a53..0277c63b343e 100644
--- a/tgui/packages/tgui/components/NoticeBox.js
+++ b/tgui/packages/tgui/components/NoticeBox.js
@@ -7,8 +7,16 @@
import { classes, pureComponentHooks } from 'common/react';
import { Box } from './Box';
-export const NoticeBox = (props) => {
- const { className, color, info, warning, success, danger, ...rest } = props;
+export const NoticeBox = props => {
+ const {
+ className,
+ color,
+ info,
+ warning,
+ success,
+ danger,
+ ...rest
+ } = props;
return (
{
danger && 'NoticeBox--type--danger',
className,
])}
- {...rest}
- />
+ {...rest} />
);
};
diff --git a/tgui/packages/tgui/components/ProgressBar.js b/tgui/packages/tgui/components/ProgressBar.js
index 86116732c3f2..cba65fd71f22 100644
--- a/tgui/packages/tgui/components/ProgressBar.js
+++ b/tgui/packages/tgui/components/ProgressBar.js
@@ -9,7 +9,7 @@ import { classes, pureComponentHooks } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
import { CSS_COLORS } from '../constants';
-export const ProgressBar = (props) => {
+export const ProgressBar = props => {
const {
className,
value,
@@ -22,7 +22,6 @@ export const ProgressBar = (props) => {
} = props;
const scaledValue = scale(value, minValue, maxValue);
const hasContent = children !== undefined;
- // prettier-ignore
const effectiveColor = color
|| keyOfMatchingRange(value, ranges)
|| 'default';
@@ -30,7 +29,6 @@ export const ProgressBar = (props) => {
// We permit colors to be in hex format, rgb()/rgba() format,
// a name for a color- class, or a base CSS class.
const outerProps = computeBoxProps(rest);
- // prettier-ignore
const outerClasses = [
'ProgressBar',
className,
@@ -44,20 +42,22 @@ export const ProgressBar = (props) => {
outerClasses.push('ProgressBar--color--' + effectiveColor);
} else {
// Otherwise, set styles directly.
- // prettier-ignore
outerProps.style = (outerProps.style || "")
+ `border-color: ${effectiveColor};`;
fillStyles['background-color'] = effectiveColor;
}
return (
-
+
+ style={fillStyles} />
- {hasContent ? children : toFixed(scaledValue * 100) + '%'}
+ {hasContent
+ ? children
+ : toFixed(scaledValue * 100) + '%'}
);
diff --git a/tgui/packages/tgui/components/RestrictedInput.js b/tgui/packages/tgui/components/RestrictedInput.js
index 1b082296ca51..0a6e2cb440c3 100644
--- a/tgui/packages/tgui/components/RestrictedInput.js
+++ b/tgui/packages/tgui/components/RestrictedInput.js
@@ -8,19 +8,17 @@ const DEFAULT_MIN = 0;
const DEFAULT_MAX = 10000;
/**
- * Takes a string input and parses integers or floats from it.
+ * Takes a string input and parses integers from it.
* If none: Minimum is set.
* Else: Clamps it to the given range.
*/
-const getClampedNumber = (value, minValue, maxValue, allowFloats) => {
+const getClampedNumber = (value, minValue, maxValue) => {
const minimum = minValue || DEFAULT_MIN;
const maximum = maxValue || maxValue === 0 ? maxValue : DEFAULT_MAX;
if (!value || !value.length) {
return String(minimum);
}
- let parsedValue = allowFloats
- ? parseFloat(value.replace(/[^\-\d.]/g, ''))
- : parseInt(value.replace(/[^\-\d]/g, ''), 10);
+ let parsedValue = parseInt(value.replace(/\D/g, ''), 10);
if (isNaN(parsedValue)) {
return String(minimum);
} else {
@@ -42,13 +40,8 @@ export class RestrictedInput extends Component {
}
};
this.handleChange = (e) => {
- const { maxValue, minValue, onChange, allowFloats } = this.props;
- e.target.value = getClampedNumber(
- e.target.value,
- minValue,
- maxValue,
- allowFloats
- );
+ const { maxValue, minValue, onChange } = this.props;
+ e.target.value = getClampedNumber(e.target.value, minValue, maxValue);
if (onChange) {
onChange(e, +e.target.value);
}
@@ -70,14 +63,9 @@ export class RestrictedInput extends Component {
}
};
this.handleKeyDown = (e) => {
- const { maxValue, minValue, onChange, onEnter, allowFloats } = this.props;
+ const { maxValue, minValue, onChange, onEnter } = this.props;
if (e.keyCode === KEY_ENTER) {
- const safeNum = getClampedNumber(
- e.target.value,
- minValue,
- maxValue,
- allowFloats
- );
+ const safeNum = getClampedNumber(e.target.value, minValue, maxValue);
this.setEditing(false);
if (onChange) {
onChange(e, +safeNum);
@@ -102,16 +90,11 @@ export class RestrictedInput extends Component {
}
componentDidMount() {
- const { maxValue, minValue, allowFloats } = this.props;
+ const { maxValue, minValue } = this.props;
const nextValue = this.props.value?.toString();
const input = this.inputRef.current;
if (input) {
- input.value = getClampedNumber(
- nextValue,
- minValue,
- maxValue,
- allowFloats
- );
+ input.value = getClampedNumber(nextValue, minValue, maxValue);
}
if (this.props.autoFocus || this.props.autoSelect) {
setTimeout(() => {
@@ -125,19 +108,14 @@ export class RestrictedInput extends Component {
}
componentDidUpdate(prevProps, _) {
- const { maxValue, minValue, allowFloats } = this.props;
+ const { maxValue, minValue } = this.props;
const { editing } = this.state;
const prevValue = prevProps.value?.toString();
const nextValue = this.props.value?.toString();
const input = this.inputRef.current;
if (input && !editing) {
if (nextValue !== prevValue && nextValue !== input.value) {
- input.value = getClampedNumber(
- nextValue,
- minValue,
- maxValue,
- allowFloats
- );
+ input.value = getClampedNumber(nextValue, minValue, maxValue);
}
}
}
diff --git a/tgui/packages/tgui/components/RoundGauge.js b/tgui/packages/tgui/components/RoundGauge.js
index fb016fa0e4c3..e68f397bdbdc 100644
--- a/tgui/packages/tgui/components/RoundGauge.js
+++ b/tgui/packages/tgui/components/RoundGauge.js
@@ -9,10 +9,12 @@ import { classes } from 'common/react';
import { AnimatedNumber } from './AnimatedNumber';
import { Box, computeBoxClassName, computeBoxProps } from './Box';
-export const RoundGauge = (props) => {
+export const RoundGauge = props => {
// Support for IE8 is for losers sorry B)
if (Byond.IS_LTE_IE8) {
- return
;
+ return (
+
+ );
}
const {
@@ -29,11 +31,14 @@ export const RoundGauge = (props) => {
...rest
} = props;
- const scaledValue = scale(value, minValue, maxValue);
+ const scaledValue = scale(
+ value,
+ minValue,
+ maxValue);
const clampedValue = clamp01(scaledValue);
- const scaledRanges = ranges ? {} : { 'primary': [0, 1] };
+ const scaledRanges = ranges ? {} : { "primary": [0, 1] };
if (ranges) {
- Object.keys(ranges).forEach((x) => {
+ Object.keys(ranges).forEach(x => {
const range = ranges[x];
scaledRanges[x] = [
scale(range[0], minValue, maxValue),
@@ -59,7 +64,6 @@ export const RoundGauge = (props) => {
return false;
};
- // prettier-ignore
const alertColor = shouldShowAlert()
&& keyOfMatchingRange(clampedValue, scaledRanges);
@@ -78,18 +82,22 @@ export const RoundGauge = (props) => {
},
...rest,
})}>
-
+
{(alertAfter || alertBefore) && (
-
+
)}
-
+
{Object.keys(scaledRanges).map((x, i) => {
@@ -99,16 +107,15 @@ export const RoundGauge = (props) => {
className={`RoundGauge__ringFill RoundGauge--color--${x}`}
key={i}
style={{
- 'stroke-dashoffset': Math.max(
- (2.0 - (col_ranges[1] - col_ranges[0])) * Math.PI * 50,
- 0
+ 'stroke-dashoffset': (
+ Math.max((2.0 - (col_ranges[1] - col_ranges[0]))
+ * Math.PI * 50, 0)
),
}}
transform={`rotate(${180 + 180 * col_ranges[0]} 50 50)`}
cx="50"
cy="50"
- r="45"
- />
+ r="45" />
);
})}
@@ -117,18 +124,19 @@ export const RoundGauge = (props) => {
transform={`rotate(${clampedValue * 180 - 90} 50 50)`}>
+ points="46,50 50,0 54,50" />
+ r="8" />
-
+
);
};
diff --git a/tgui/packages/tgui/components/Section.tsx b/tgui/packages/tgui/components/Section.tsx
index d1507f59d8b6..ee79d2b6aba1 100644
--- a/tgui/packages/tgui/components/Section.tsx
+++ b/tgui/packages/tgui/components/Section.tsx
@@ -9,49 +9,37 @@ import { Component, createRef, InfernoNode, RefObject } from 'inferno';
import { addScrollableNode, removeScrollableNode } from '../events';
import { BoxProps, computeBoxClassName, computeBoxProps } from './Box';
-type SectionProps = BoxProps & {
+interface SectionProps extends BoxProps {
className?: string;
title?: InfernoNode;
buttons?: InfernoNode;
fill?: boolean;
fitted?: boolean;
scrollable?: boolean;
- scrollableHorizontal?: boolean;
/** @deprecated This property no longer works, please remove it. */
level?: boolean;
/** @deprecated Please use `scrollable` property */
overflowY?: any;
- /** @member Allows external control of scrolling. */
- scrollableRef?: RefObject;
- /** @member Callback function for the `scroll` event */
- onScroll?: (this: GlobalEventHandlers, ev: Event) => any;
-};
+}
export class Section extends Component {
scrollableRef: RefObject;
scrollable: boolean;
- onScroll?: (this: GlobalEventHandlers, ev: Event) => any;
- scrollableHorizontal: boolean;
constructor(props) {
super(props);
- this.scrollableRef = props.scrollableRef || createRef();
+ this.scrollableRef = createRef();
this.scrollable = props.scrollable;
- this.onScroll = props.onScroll;
- this.scrollableHorizontal = props.scrollableHorizontal;
}
componentDidMount() {
- if (this.scrollable || this.scrollableHorizontal) {
+ if (this.scrollable) {
addScrollableNode(this.scrollableRef.current);
- if (this.onScroll && this.scrollableRef.current) {
- this.scrollableRef.current.onscroll = this.onScroll;
- }
}
}
componentWillUnmount() {
- if (this.scrollable || this.scrollableHorizontal) {
+ if (this.scrollable) {
removeScrollableNode(this.scrollableRef.current);
}
}
@@ -64,9 +52,7 @@ export class Section extends Component {
fill,
fitted,
scrollable,
- scrollableHorizontal,
children,
- onScroll,
...rest
} = this.props;
const hasTitle = canRender(title) || canRender(buttons);
@@ -78,22 +64,22 @@ export class Section extends Component {
fill && 'Section--fill',
fitted && 'Section--fitted',
scrollable && 'Section--scrollable',
- scrollableHorizontal && 'Section--scrollableHorizontal',
className,
computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}>
{hasTitle && (
-
{title}
-
{buttons}
+
+ {title}
+
+
+ {buttons}
+
)}
-
diff --git a/tgui/packages/tgui/components/Slider.js b/tgui/packages/tgui/components/Slider.js
index 84ee8421a9a6..005e6c1f8f78 100644
--- a/tgui/packages/tgui/components/Slider.js
+++ b/tgui/packages/tgui/components/Slider.js
@@ -10,10 +10,12 @@ import { computeBoxClassName, computeBoxProps } from './Box';
import { DraggableControl } from './DraggableControl';
import { NumberInput } from './NumberInput';
-export const Slider = (props) => {
+export const Slider = props => {
// IE8: I don't want to support a yet another component on IE8.
if (Byond.IS_LTE_IE8) {
- return
;
+ return (
+
+ );
}
const {
// Draggable props (passthrough)
@@ -53,7 +55,7 @@ export const Slider = (props) => {
unit,
value,
}}>
- {(control) => {
+ {control => {
const {
dragging,
editing,
@@ -63,17 +65,23 @@ export const Slider = (props) => {
inputElement,
handleDragStart,
} = control;
- const hasFillValue = fillValue !== undefined && fillValue !== null;
- const scaledValue = scale(value, minValue, maxValue);
+ const hasFillValue = fillValue !== undefined
+ && fillValue !== null;
+ const scaledValue = scale(
+ value,
+ minValue,
+ maxValue);
const scaledFillValue = scale(
fillValue ?? displayValue,
minValue,
- maxValue
- );
- const scaledDisplayValue = scale(displayValue, minValue, maxValue);
- // prettier-ignore
+ maxValue);
+ const scaledDisplayValue = scale(
+ displayValue,
+ minValue,
+ maxValue);
const effectiveColor = color
- || keyOfMatchingRange(fillValue ?? value, ranges) || 'default';
+ || keyOfMatchingRange(fillValue ?? value, ranges)
+ || 'default';
return (
{
style={{
width: clamp01(scaledFillValue) * 100 + '%',
opacity: 0.4,
- }}
- />
+ }} />
+ }} />
{
{dragging && (
-
{displayElement}
+
+ {displayElement}
+
)}
- {hasContent ? children : displayElement}
+ {hasContent
+ ? children
+ : displayElement}
{inputElement}
diff --git a/tgui/packages/tgui/components/Stack.tsx b/tgui/packages/tgui/components/Stack.tsx
index 9abf13e651b7..54c5e723bce7 100644
--- a/tgui/packages/tgui/components/Stack.tsx
+++ b/tgui/packages/tgui/components/Stack.tsx
@@ -20,7 +20,9 @@ export const Stack = (props: StackProps) => {
className={classes([
'Stack',
fill && 'Stack--fill',
- vertical ? 'Stack--vertical' : 'Stack--horizontal',
+ vertical
+ ? 'Stack--vertical'
+ : 'Stack--horizontal',
className,
computeFlexClassName(props),
])}
@@ -33,7 +35,7 @@ export const Stack = (props: StackProps) => {
};
type StackItemProps = FlexProps & {
- innerRef?: RefObject
;
+ innerRef?: RefObject,
};
const StackItem = (props: StackItemProps) => {
diff --git a/tgui/packages/tgui/components/Table.js b/tgui/packages/tgui/components/Table.js
index e936b9144808..545e26f5934f 100644
--- a/tgui/packages/tgui/components/Table.js
+++ b/tgui/packages/tgui/components/Table.js
@@ -7,8 +7,13 @@
import { classes, pureComponentHooks } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
-export const Table = (props) => {
- const { className, collapsing, children, ...rest } = props;
+export const Table = props => {
+ const {
+ className,
+ collapsing,
+ children,
+ ...rest
+ } = props;
return (
{
computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}>
- {children}
+
+ {children}
+
);
};
Table.defaultHooks = pureComponentHooks;
-export const TableRow = (props) => {
- const { className, header, ...rest } = props;
+export const TableRow = props => {
+ const {
+ className,
+ header,
+ ...rest
+ } = props;
return (
{
className,
computeBoxClassName(props),
])}
- {...computeBoxProps(rest)}
- />
+ {...computeBoxProps(rest)} />
);
};
TableRow.defaultHooks = pureComponentHooks;
-export const TableCell = (props) => {
- const { className, collapsing, header, ...rest } = props;
+export const TableCell = props => {
+ const {
+ className,
+ collapsing,
+ header,
+ ...rest
+ } = props;
return (
{
className,
computeBoxClassName(props),
])}
- {...computeBoxProps(rest)}
- />
+ {...computeBoxProps(rest)} />
);
};
diff --git a/tgui/packages/tgui/components/Tabs.js b/tgui/packages/tgui/components/Tabs.js
index 46d1b078a85e..ca0453f8da34 100644
--- a/tgui/packages/tgui/components/Tabs.js
+++ b/tgui/packages/tgui/components/Tabs.js
@@ -8,13 +8,22 @@ import { canRender, classes } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
import { Icon } from './Icon';
-export const Tabs = (props) => {
- const { className, vertical, fill, fluid, children, ...rest } = props;
+export const Tabs = props => {
+ const {
+ className,
+ vertical,
+ fill,
+ fluid,
+ children,
+ ...rest
+ } = props;
return (
{
);
};
-const Tab = (props) => {
+const Tab = props => {
const {
className,
selected,
@@ -48,14 +57,23 @@ const Tab = (props) => {
...computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}>
- {(canRender(leftSlot) &&
{leftSlot}
) ||
- (!!icon && (
-
-
-
- ))}
-
{children}
- {canRender(rightSlot) &&
{rightSlot}
}
+ {canRender(leftSlot) && (
+
+ {leftSlot}
+
+ ) || !!icon && (
+
+
+
+ )}
+
+ {children}
+
+ {canRender(rightSlot) && (
+
+ {rightSlot}
+
+ )}
);
};
diff --git a/tgui/packages/tgui/components/TextArea.js b/tgui/packages/tgui/components/TextArea.js
index 3fe2cd985d4a..4b6fda547cb5 100644
--- a/tgui/packages/tgui/components/TextArea.js
+++ b/tgui/packages/tgui/components/TextArea.js
@@ -9,18 +9,20 @@ import { classes } from 'common/react';
import { Component, createRef } from 'inferno';
import { Box } from './Box';
import { toInputValue } from './Input';
-import { KEY_ENTER, KEY_ESCAPE, KEY_TAB } from 'common/keycodes';
+import { KEY_ESCAPE } from 'common/keycodes';
export class TextArea extends Component {
constructor(props, context) {
super(props, context);
- this.textareaRef = props.innerRef || createRef();
+ this.textareaRef = createRef();
+ this.fillerRef = createRef();
this.state = {
editing: false,
- scrolledAmount: 0,
};
- const { dontUseTabForIndent = false } = props;
- this.handleOnInput = (e) => {
+ const {
+ dontUseTabForIndent = false,
+ } = props;
+ this.handleOnInput = e => {
const { editing } = this.state;
const { onInput } = this.props;
if (!editing) {
@@ -30,7 +32,7 @@ export class TextArea extends Component {
onInput(e, e.target.value);
}
};
- this.handleOnChange = (e) => {
+ this.handleOnChange = e => {
const { editing } = this.state;
const { onChange } = this.props;
if (editing) {
@@ -40,7 +42,7 @@ export class TextArea extends Component {
onChange(e, e.target.value);
}
};
- this.handleKeyPress = (e) => {
+ this.handleKeyPress = e => {
const { editing } = this.state;
const { onKeyPress } = this.props;
if (!editing) {
@@ -50,69 +52,41 @@ export class TextArea extends Component {
onKeyPress(e, e.target.value);
}
};
- this.handleKeyDown = (e) => {
+ this.handleKeyDown = e => {
const { editing } = this.state;
- const { onChange, onInput, onEnter, onKey } = this.props;
- if (e.keyCode === KEY_ENTER) {
- this.setEditing(false);
- if (onChange) {
- onChange(e, e.target.value);
- }
- if (onInput) {
- onInput(e, e.target.value);
- }
- if (onEnter) {
- onEnter(e, e.target.value);
- }
- if (this.props.selfClear) {
- e.target.value = '';
- e.target.blur();
- }
- return;
- }
+ const { onKeyDown } = this.props;
if (e.keyCode === KEY_ESCAPE) {
- if (this.props.onEscape) {
- this.props.onEscape(e);
- }
this.setEditing(false);
- if (this.props.selfClear) {
- e.target.value = '';
- } else {
- e.target.value = toInputValue(this.props.value);
- e.target.blur();
- }
+ e.target.value = toInputValue(this.props.value);
+ e.target.blur();
return;
}
if (!editing) {
this.setEditing(true);
}
- // Custom key handler
- if (onKey) {
- onKey(e, e.target.value);
- }
if (!dontUseTabForIndent) {
const keyCode = e.keyCode || e.which;
- if (keyCode === KEY_TAB) {
+ if (keyCode === 9) {
e.preventDefault();
const { value, selectionStart, selectionEnd } = e.target;
- e.target.value =
- value.substring(0, selectionStart) +
- '\t' +
- value.substring(selectionEnd);
+ e.target.value = (
+ value.substring(0, selectionStart) + "\t"
+ + value.substring(selectionEnd)
+ );
e.target.selectionEnd = selectionStart + 1;
- if (onInput) {
- onInput(e, e.target.value);
- }
}
}
+ if (onKeyDown) {
+ onKeyDown(e, e.target.value);
+ }
};
- this.handleFocus = (e) => {
+ this.handleFocus = e => {
const { editing } = this.state;
if (!editing) {
this.setEditing(true);
}
};
- this.handleBlur = (e) => {
+ this.handleBlur = e => {
const { editing } = this.state;
const { onChange } = this.props;
if (editing) {
@@ -122,15 +96,6 @@ export class TextArea extends Component {
}
}
};
- this.handleScroll = (e) => {
- const { displayedValue } = this.props;
- const input = this.textareaRef.current;
- if (displayedValue && input) {
- this.setState({
- scrolledAmount: input.scrollTop,
- });
- }
- };
}
componentDidMount() {
@@ -139,6 +104,7 @@ export class TextArea extends Component {
if (input) {
input.value = toInputValue(nextValue);
}
+
if (this.props.autoFocus || this.props.autoSelect) {
setTimeout(() => {
input.focus();
@@ -151,10 +117,11 @@ export class TextArea extends Component {
}
componentDidUpdate(prevProps, prevState) {
+ const { editing } = this.state;
const prevValue = prevProps.value;
const nextValue = this.props.value;
const input = this.textareaRef.current;
- if (input && typeof nextValue === 'string' && prevValue !== nextValue) {
+ if (input && !editing && prevValue !== nextValue) {
input.value = toInputValue(nextValue);
}
}
@@ -180,44 +147,25 @@ export class TextArea extends Component {
value,
maxLength,
placeholder,
- scrollbar,
- noborder,
- displayedValue,
...boxProps
} = this.props;
// Box props
- const { className, fluid, nowrap, ...rest } = boxProps;
- const { scrolledAmount } = this.state;
+ const {
+ className,
+ fluid,
+ ...rest
+ } = boxProps;
return (
- {!!displayedValue && (
-
-
- {displayedValue}
-
-
- )}
+ maxLength={maxLength} />
);
}
diff --git a/tgui/packages/tgui/components/TimeDisplay.js b/tgui/packages/tgui/components/TimeDisplay.js
index 6b87ee5260ac..fdba84563a8c 100644
--- a/tgui/packages/tgui/components/TimeDisplay.js
+++ b/tgui/packages/tgui/components/TimeDisplay.js
@@ -2,10 +2,10 @@ import { formatTime } from '../format';
import { Component } from 'inferno';
// AnimatedNumber Copypaste
-const isSafeNumber = (value) => {
- return (
- typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value)
- );
+const isSafeNumber = value => {
+ return typeof value === 'number'
+ && Number.isFinite(value)
+ && !Number.isNaN(value);
};
export class TimeDisplay extends Component {
@@ -36,7 +36,7 @@ export class TimeDisplay extends Component {
this.last_seen_value = this.props.value;
current = this.props.value;
}
- const mod = this.props.auto === 'up' ? 10 : -10; // Time down by default.
+ const mod = this.props.auto === "up" ? 10 : -10; // Time down by default.
const value = Math.max(0, current + mod); // one sec tick
this.setState({ value });
}
diff --git a/tgui/packages/tgui/components/Tooltip.js b/tgui/packages/tgui/components/Tooltip.js
deleted file mode 100644
index 5c0aad61a914..000000000000
--- a/tgui/packages/tgui/components/Tooltip.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @file
- * @copyright 2020 Aleksej Komarov
- * @license MIT
- */
-
-import { classes } from 'common/react';
-
-export const Tooltip = props => {
- const {
- content,
- overrideLong = false,
- position = 'bottom',
- } = props;
- // Empirically calculated length of the string,
- // at which tooltip text starts to overflow.
- const long = typeof content === 'string'
- && (content.length > 35 && !overrideLong);
- return (
-
- );
-};
diff --git a/tgui/packages/tgui/components/Tooltip.tsx b/tgui/packages/tgui/components/Tooltip.tsx
index cb35425e7401..0cf61ed04fc9 100644
--- a/tgui/packages/tgui/components/Tooltip.tsx
+++ b/tgui/packages/tgui/components/Tooltip.tsx
@@ -1,54 +1,31 @@
-import { createPopper, Placement, VirtualElement } from '@popperjs/core';
-import { Component, findDOMfromVNode, InfernoNode, render } from 'inferno';
+
+import { Placement } from '@popperjs/core';
+import { Component, findDOMfromVNode, InfernoNode } from 'inferno';
+import { Popper } from "./Popper";
+
+const DEFAULT_PLACEMENT = "top";
type TooltipProps = {
children?: InfernoNode;
content: InfernoNode;
- position?: Placement;
+ position?: Placement,
};
type TooltipState = {
hovered: boolean;
};
-const DEFAULT_OPTIONS = {
- 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,
-};
+const DISABLE_EVENT_LISTENERS = [{
+ name: "eventListeners",
+ enabled: false,
+}];
export class Tooltip extends Component {
- // Mounting poppers is really laggy because popper.js is very slow.
- // Thus, instead of using the Popper component, Tooltip creates ONE popper
- // and stores every tooltip inside that.
- // This means you can never have two tooltips at once, for instance.
- static renderedTooltip: HTMLDivElement | undefined;
- static singletonPopper: ReturnType | undefined;
- static currentHoveredElement: Element | undefined;
- static virtualElement: VirtualElement = {
- // prettier-ignore
- getBoundingClientRect: () => (
- Tooltip.currentHoveredElement?.getBoundingClientRect()
- ?? NULL_RECT
- ),
+ state = {
+ hovered: false,
};
- getDOMNode() {
+ componentDidMount() {
// HACK: We don't want to create a wrapper, as it could break the layout
// of consumers, so we do the inferno equivalent of `findDOMNode(this)`.
// My attempt to avoid this was a render prop that passed in
@@ -57,94 +34,47 @@ export class Tooltip extends Component {
// This code is copied from `findDOMNode` in inferno-extras.
// Because this component is written in TypeScript, we will know
// immediately if this internal variable is removed.
- return findDOMfromVNode(this.$LI, true);
- }
-
- componentDidMount() {
- const domNode = this.getDOMNode();
+ const domNode = findDOMfromVNode(this.$LI, true);
if (!domNode) {
return;
}
- domNode.addEventListener('mouseenter', () => {
- let renderedTooltip = Tooltip.renderedTooltip;
- if (renderedTooltip === undefined) {
- renderedTooltip = document.createElement('div');
- renderedTooltip.className = 'Tooltip';
- document.body.appendChild(renderedTooltip);
- Tooltip.renderedTooltip = renderedTooltip;
- }
-
- Tooltip.currentHoveredElement = domNode;
-
- renderedTooltip.style.opacity = '1';
-
- this.renderPopperContent();
+ domNode.addEventListener("mouseenter", () => {
+ this.setState({
+ hovered: true,
+ });
});
- domNode.addEventListener('mouseleave', () => {
- this.fadeOut();
+ domNode.addEventListener("mouseleave", () => {
+ this.setState({
+ hovered: false,
+ });
});
}
- fadeOut() {
- if (Tooltip.currentHoveredElement !== this.getDOMNode()) {
- return;
- }
-
- Tooltip.currentHoveredElement = undefined;
- Tooltip.renderedTooltip!.style.opacity = '0';
- }
-
- renderPopperContent() {
- const renderedTooltip = Tooltip.renderedTooltip;
- if (!renderedTooltip) {
- return;
- }
-
- render(
- {this.props.content} ,
- renderedTooltip,
- () => {
- let singletonPopper = Tooltip.singletonPopper;
- if (singletonPopper === undefined) {
- singletonPopper = createPopper(
- Tooltip.virtualElement,
- renderedTooltip!,
- {
- ...DEFAULT_OPTIONS,
- placement: this.props.position || 'auto',
- }
- );
-
- Tooltip.singletonPopper = singletonPopper;
- } else {
- singletonPopper.setOptions({
- ...DEFAULT_OPTIONS,
- placement: this.props.position || 'auto',
- });
-
- singletonPopper.update();
+ render() {
+ return (
+
+ {this.props.content}
+
}
- },
- this.context
+ additionalStyles={{
+ "pointer-events": "none",
+ "z-index": 2,
+ }}>
+ {this.props.children}
+
);
}
-
- componentDidUpdate() {
- if (Tooltip.currentHoveredElement !== this.getDOMNode()) {
- return;
- }
-
- this.renderPopperContent();
- }
-
- componentWillUnmount() {
- this.fadeOut();
- }
-
- render() {
- return this.props.children;
- }
}
diff --git a/tgui/packages/tgui/components/TrackOutsideClicks.tsx b/tgui/packages/tgui/components/TrackOutsideClicks.tsx
index 578b4f55db07..fc52eba7e6c1 100644
--- a/tgui/packages/tgui/components/TrackOutsideClicks.tsx
+++ b/tgui/packages/tgui/components/TrackOutsideClicks.tsx
@@ -1,10 +1,8 @@
-import { Component, createRef } from 'inferno';
+import { Component, createRef } from "inferno";
-type Props = {
- onOutsideClick: () => void;
-};
-
-export class TrackOutsideClicks extends Component {
+export class TrackOutsideClicks extends Component<{
+ onOutsideClick: () => void,
+}> {
ref = createRef();
constructor() {
@@ -12,11 +10,11 @@ export class TrackOutsideClicks extends Component {
this.handleOutsideClick = this.handleOutsideClick.bind(this);
- document.addEventListener('click', this.handleOutsideClick);
+ document.addEventListener("click", this.handleOutsideClick);
}
componentWillUnmount() {
- document.removeEventListener('click', this.handleOutsideClick);
+ document.removeEventListener("click", this.handleOutsideClick);
}
handleOutsideClick(event: MouseEvent) {
@@ -30,6 +28,11 @@ export class TrackOutsideClicks extends Component {
}
render() {
- return {this.props.children}
;
+ return (
+
+ {this.props.children}
+
+ );
}
}
+
diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js
index cce830012250..712cf1eca5b6 100644
--- a/tgui/packages/tgui/components/index.js
+++ b/tgui/packages/tgui/components/index.js
@@ -24,6 +24,7 @@ export { Grid } from './Grid';
export { Icon } from './Icon';
export { InfinitePlane } from './InfinitePlane';
export { Input } from './Input';
+export { KeyListener } from './KeyListener';
export { Knob } from './Knob';
export { LabeledControls } from './LabeledControls';
export { LabeledList } from './LabeledList';
diff --git a/tgui/packages/tgui/debug/middleware.js b/tgui/packages/tgui/debug/middleware.js
index 8dceaf9573ca..92e64fe3bd12 100644
--- a/tgui/packages/tgui/debug/middleware.js
+++ b/tgui/packages/tgui/debug/middleware.js
@@ -44,7 +44,7 @@ export const relayMiddleware = store => {
if (externalBrowser) {
devServer.subscribe(msg => {
const { type, payload } = msg;
- if (type === 'relay' && payload.windowId === window.__windowId__) {
+ if (type === 'relay' && payload.windowId === Byond.windowId) {
store.dispatch({
...payload.action,
relayed: true,
@@ -70,7 +70,7 @@ export const relayMiddleware = store => {
devServer.sendMessage({
type: 'relay',
payload: {
- windowId: window.__windowId__,
+ windowId: Byond.windowId,
action,
},
});
diff --git a/tgui/packages/tgui/drag.js b/tgui/packages/tgui/drag.js
index d6f0967a8ddd..671ed6eaa8b6 100644
--- a/tgui/packages/tgui/drag.js
+++ b/tgui/packages/tgui/drag.js
@@ -5,12 +5,13 @@
*/
import { storage } from 'common/storage';
-import { vecAdd, vecInverse, vecMultiply, vecScale } from 'common/vector';
+import { vecAdd, vecSubtract, vecMultiply, vecScale } from 'common/vector';
import { createLogger } from './logging';
const logger = createLogger('drag');
+const pixelRatio = window.devicePixelRatio ?? 1;
-let windowKey = window.__windowId__;
+let windowKey = Byond.windowId;
let dragging = false;
let resizing = false;
let screenOffset = [0, 0];
@@ -24,37 +25,37 @@ export const setWindowKey = key => {
windowKey = key;
};
-export const getWindowPosition = () => [
- window.screenLeft,
- window.screenTop,
+const getWindowPosition = () => [
+ window.screenLeft * pixelRatio,
+ window.screenTop * pixelRatio,
];
-export const getWindowSize = () => [
- window.innerWidth,
- window.innerHeight,
+const getWindowSize = () => [
+ window.innerWidth * pixelRatio,
+ window.innerHeight * pixelRatio,
];
-export const setWindowPosition = vec => {
+const setWindowPosition = vec => {
const byondPos = vecAdd(vec, screenOffset);
- return Byond.winset(window.__windowId__, {
+ return Byond.winset(Byond.windowId, {
pos: byondPos[0] + ',' + byondPos[1],
});
};
-export const setWindowSize = vec => {
- return Byond.winset(window.__windowId__, {
+const setWindowSize = vec => {
+ return Byond.winset(Byond.windowId, {
size: vec[0] + 'x' + vec[1],
});
};
-export const getScreenPosition = () => [
+const getScreenPosition = () => [
0 - screenOffset[0],
0 - screenOffset[1],
];
-export const getScreenSize = () => [
- window.screen.availWidth,
- window.screen.availHeight,
+const getScreenSize = () => [
+ window.screen.availWidth * pixelRatio,
+ window.screen.availHeight * pixelRatio,
];
/**
@@ -83,7 +84,7 @@ const touchRecents = (recents, touchedItem, limit = 50) => {
return [nextRecents, trimmedItem];
};
-export const storeWindowGeometry = async () => {
+const storeWindowGeometry = async () => {
logger.log('storing geometry');
const geometry = {
pos: getWindowPosition(),
@@ -106,17 +107,22 @@ export const recallWindowGeometry = async (options = {}) => {
if (geometry) {
logger.log('recalled geometry:', geometry);
}
+ // options.pos is assumed to already be in display-pixels
let pos = geometry?.pos || options.pos;
let size = options.size;
+ // Convert size from css-pixels to display-pixels
+ if (size) {
+ size = [
+ size[0] * pixelRatio,
+ size[1] * pixelRatio,
+ ];
+ }
// Wait until screen offset gets resolved
await screenOffsetPromise;
- const areaAvailable = [
- window.screen.availWidth,
- window.screen.availHeight,
- ];
+ const areaAvailable = getScreenSize();
// Set window size
if (size) {
- // Constraint size to not exceed available screen area.
+ // Constraint size to not exceed available screen area
size = [
Math.min(areaAvailable[0], size[0]),
Math.min(areaAvailable[1], size[1]),
@@ -143,10 +149,12 @@ export const recallWindowGeometry = async (options = {}) => {
export const setupDrag = async () => {
// Calculate screen offset caused by the windows taskbar
- screenOffsetPromise = Byond.winget(window.__windowId__, 'pos')
+ let windowPosition = getWindowPosition();
+
+ screenOffsetPromise = Byond.winget(Byond.windowId, 'pos')
.then(pos => [
- pos.x - window.screenLeft,
- pos.y - window.screenTop,
+ pos.x - windowPosition[0],
+ pos.y - windowPosition[1],
]);
screenOffset = await screenOffsetPromise;
logger.debug('screen offset', screenOffset);
@@ -179,10 +187,10 @@ const constraintPosition = (pos, size) => {
export const dragStartHandler = event => {
logger.log('drag start');
dragging = true;
- dragPointOffset = [
- window.screenLeft - event.screenX,
- window.screenTop - event.screenY,
- ];
+ let windowPosition = getWindowPosition();
+ dragPointOffset = vecSubtract(
+ [event.screenX, event.screenY],
+ getWindowPosition());
// Focus click target
event.target?.focus();
document.addEventListener('mousemove', dragMoveHandler);
@@ -204,7 +212,7 @@ const dragMoveHandler = event => {
return;
}
event.preventDefault();
- setWindowPosition(vecAdd(
+ setWindowPosition(vecSubtract(
[event.screenX, event.screenY],
dragPointOffset));
};
@@ -213,14 +221,10 @@ export const resizeStartHandler = (x, y) => event => {
resizeMatrix = [x, y];
logger.log('resize start', resizeMatrix);
resizing = true;
- dragPointOffset = [
- window.screenLeft - event.screenX,
- window.screenTop - event.screenY,
- ];
- initialSize = [
- window.innerWidth,
- window.innerHeight,
- ];
+ dragPointOffset = vecSubtract(
+ [event.screenX, event.screenY],
+ getWindowPosition());
+ initialSize = getWindowSize();
// Focus click target
event.target?.focus();
document.addEventListener('mousemove', resizeMoveHandler);
@@ -242,13 +246,19 @@ const resizeMoveHandler = event => {
return;
}
event.preventDefault();
- size = vecAdd(initialSize, vecMultiply(resizeMatrix, vecAdd(
+ const currentOffset = vecSubtract(
[event.screenX, event.screenY],
- vecInverse([window.screenLeft, window.screenTop]),
- dragPointOffset,
- [1, 1])));
+ getWindowPosition());
+ const delta = vecSubtract(
+ currentOffset,
+ dragPointOffset);
+ // Extra 1x1 area is added to ensure the browser can see the cursor
+ size = vecAdd(
+ initialSize,
+ vecMultiply(resizeMatrix, delta),
+ [1, 1]);
// Sane window size values
- size[0] = Math.max(size[0], 150);
- size[1] = Math.max(size[1], 50);
+ size[0] = Math.max(size[0], 150 * pixelRatio);
+ size[1] = Math.max(size[1], 50 * pixelRatio);
setWindowSize(size);
};
diff --git a/tgui/packages/tgui/events.js b/tgui/packages/tgui/events.js
index a27f1e192549..a0bc2ab2da32 100644
--- a/tgui/packages/tgui/events.js
+++ b/tgui/packages/tgui/events.js
@@ -16,7 +16,6 @@ export const setupGlobalEvents = (options = {}) => {
ignoreWindowFocus = !!options.ignoreWindowFocus;
};
-
// Window focus
// --------------------------------------------------------
@@ -44,18 +43,17 @@ const setWindowFocus = (value, delayed) => {
}
};
-
// Focus stealing
// --------------------------------------------------------
let focusStolenBy = null;
-export const canStealFocus = node => {
+export const canStealFocus = (node) => {
const tag = String(node.tagName).toLowerCase();
return tag === 'input' || tag === 'textarea';
};
-const stealFocus = node => {
+const stealFocus = (node) => {
releaseStolenFocus();
focusStolenBy = node;
focusStolenBy.addEventListener('blur', releaseStolenFocus);
@@ -68,7 +66,6 @@ const releaseStolenFocus = () => {
}
};
-
// Focus follows the mouse
// --------------------------------------------------------
@@ -76,18 +73,18 @@ let focusedNode = null;
let lastVisitedNode = null;
const trackedNodes = [];
-export const addScrollableNode = node => {
+export const addScrollableNode = (node) => {
trackedNodes.push(node);
};
-export const removeScrollableNode = node => {
+export const removeScrollableNode = (node) => {
const index = trackedNodes.indexOf(node);
if (index >= 0) {
trackedNodes.splice(index, 1);
}
};
-const focusNearestTrackedParent = node => {
+const focusNearestTrackedParent = (node) => {
if (focusStolenBy || !windowFocused) {
return;
}
@@ -106,7 +103,7 @@ const focusNearestTrackedParent = node => {
}
};
-window.addEventListener('mousemove', e => {
+window.addEventListener('mousemove', (e) => {
const node = e.target;
if (node !== lastVisitedNode) {
lastVisitedNode = node;
@@ -114,11 +111,10 @@ window.addEventListener('mousemove', e => {
}
});
-
// Focus event hooks
// --------------------------------------------------------
-window.addEventListener('focusin', e => {
+window.addEventListener('focusin', (e) => {
lastVisitedNode = null;
focusedNode = e.target;
setWindowFocus(true);
@@ -128,27 +124,26 @@ window.addEventListener('focusin', e => {
}
});
-window.addEventListener('focusout', e => {
+window.addEventListener('focusout', (e) => {
lastVisitedNode = null;
setWindowFocus(false, true);
});
-window.addEventListener('blur', e => {
+window.addEventListener('blur', (e) => {
lastVisitedNode = null;
setWindowFocus(false, true);
});
-window.addEventListener('beforeunload', e => {
+window.addEventListener('beforeunload', (e) => {
setWindowFocus(false);
});
-
// Key events
// --------------------------------------------------------
const keyHeldByCode = {};
-class KeyEvent {
+export class KeyEvent {
constructor(e, type, repeat) {
this.event = e;
this.type = type;
@@ -164,6 +159,7 @@ class KeyEvent {
}
isModifierKey() {
+ // prettier-ignore
return this.code === KEY_CTRL
|| this.code === KEY_SHIFT
|| this.code === KEY_ALT;
@@ -193,11 +189,9 @@ class KeyEvent {
}
if (this.code >= 48 && this.code <= 90) {
this._str += String.fromCharCode(this.code);
- }
- else if (this.code >= KEY_F1 && this.code <= KEY_F12) {
+ } else if (this.code >= KEY_F1 && this.code <= KEY_F12) {
this._str += 'F' + (this.code - 111);
- }
- else {
+ } else {
this._str += '[' + this.code + ']';
}
return this._str;
@@ -205,7 +199,7 @@ class KeyEvent {
}
// IE8: Keydown event is only available on document.
-document.addEventListener('keydown', e => {
+document.addEventListener('keydown', (e) => {
if (canStealFocus(e.target)) {
return;
}
@@ -216,7 +210,7 @@ document.addEventListener('keydown', e => {
keyHeldByCode[code] = true;
});
-document.addEventListener('keyup', e => {
+document.addEventListener('keyup', (e) => {
if (canStealFocus(e.target)) {
return;
}
diff --git a/tgui/packages/tgui/focus.js b/tgui/packages/tgui/focus.js
index 747a5e57f1c0..bb97b75d3b32 100644
--- a/tgui/packages/tgui/focus.js
+++ b/tgui/packages/tgui/focus.js
@@ -7,7 +7,7 @@
*/
/**
- * Moves focus to the BYOND map window. //
+ * Moves focus to the BYOND map window.
*/
export const focusMap = () => {
Byond.winset('mapwindow.map', {
@@ -19,7 +19,7 @@ export const focusMap = () => {
* Moves focus to the browser window.
*/
export const focusWindow = () => {
- Byond.winset(window.__windowId__, {
+ Byond.winset(Byond.windowId, {
focus: true,
});
};
diff --git a/tgui/packages/tgui/hotkeys.js b/tgui/packages/tgui/hotkeys.ts
similarity index 60%
rename from tgui/packages/tgui/hotkeys.js
rename to tgui/packages/tgui/hotkeys.ts
index 4e5412c7f7a4..f7176bd00300 100644
--- a/tgui/packages/tgui/hotkeys.js
+++ b/tgui/packages/tgui/hotkeys.ts
@@ -4,34 +4,40 @@
* @license MIT
*/
-import { KEY_CTRL, KEY_ENTER, KEY_ESCAPE, KEY_F, KEY_F5, KEY_R, KEY_SHIFT, KEY_SPACE, KEY_TAB } from 'common/keycodes';
-import { globalEvents } from './events';
+import * as keycodes from 'common/keycodes';
+import { globalEvents, KeyEvent } from './events';
import { createLogger } from './logging';
const logger = createLogger('hotkeys');
// BYOND macros, in `key: command` format.
-const byondMacros = {};
+const byondMacros: Record = {};
-// Array of acquired keys, which will not be sent to BYOND.
+// Default set of acquired keys, which will not be sent to BYOND.
const hotKeysAcquired = [
- // Default set of acquired keys
- KEY_ESCAPE,
- KEY_ENTER,
- KEY_SPACE,
- KEY_TAB,
- KEY_CTRL,
- KEY_SHIFT,
- KEY_F5,
+ keycodes.KEY_ESCAPE,
+ keycodes.KEY_ENTER,
+ keycodes.KEY_SPACE,
+ keycodes.KEY_TAB,
+ keycodes.KEY_CTRL,
+ keycodes.KEY_SHIFT,
+ keycodes.KEY_UP,
+ keycodes.KEY_DOWN,
+ keycodes.KEY_LEFT,
+ keycodes.KEY_RIGHT,
+ keycodes.KEY_F5,
];
// State of passed-through keys.
-const keyState = {};
+const keyState: Record = {};
+
+// Custom listeners for key events
+const keyListeners: ((key: KeyEvent) => void)[] = [];
/**
* Converts a browser keycode to BYOND keycode.
*/
-const keyCodeToByond = keyCode => {
+const keyCodeToByond = (keyCode: number) => {
if (keyCode === 16) return 'Shift';
if (keyCode === 17) return 'Ctrl';
if (keyCode === 18) return 'Alt';
@@ -45,6 +51,7 @@ const keyCodeToByond = keyCode => {
if (keyCode === 40) return 'South';
if (keyCode === 45) return 'Insert';
if (keyCode === 46) return 'Delete';
+ // prettier-ignore
if (keyCode >= 48 && keyCode <= 57 || keyCode >= 65 && keyCode <= 90) {
return String.fromCharCode(keyCode);
}
@@ -63,20 +70,24 @@ const keyCodeToByond = keyCode => {
* Keyboard passthrough logic. This allows you to keep doing things
* in game while the browser window is focused.
*/
-const handlePassthrough = key => {
+const handlePassthrough = (key: KeyEvent) => {
+ const keyString = String(key);
// In addition to F5, support reloading with Ctrl+R and Ctrl+F5
- if (key.ctrl && (key.code === KEY_F5 || key.code === KEY_R)) {
+ if (keyString === 'Ctrl+F5' || keyString === 'Ctrl+R') {
location.reload();
return;
}
// Prevent passthrough on Ctrl+F
- if (key.ctrl && key.code === KEY_F) {
+ if (keyString === 'Ctrl+F') {
return;
}
// NOTE: Alt modifier is pretty bad and sticky in IE11.
- if (key.event.defaultPrevented
- || key.isModifierKey()
- || hotKeysAcquired.includes(key.code)) {
+ // prettier-ignore
+ if (
+ key.event.defaultPrevented
+ || key.isModifierKey()
+ || hotKeysAcquired.includes(key.code)
+ ) {
return;
}
const byondKeyCode = keyCodeToByond(key.code);
@@ -109,14 +120,14 @@ const handlePassthrough = key => {
* Acquires a lock on the hotkey, which prevents it from being
* passed through to BYOND.
*/
-export const acquireHotKey = keyCode => {
+export const acquireHotKey = (keyCode: number) => {
hotKeysAcquired.push(keyCode);
};
/**
* Makes the hotkey available to BYOND again.
*/
-export const releaseHotKey = keyCode => {
+export const releaseHotKey = (keyCode: number) => {
const index = hotKeysAcquired.indexOf(keyCode);
if (index >= 0) {
hotKeysAcquired.splice(index, 1);
@@ -133,25 +144,34 @@ export const releaseHeldKeys = () => {
}
};
+type ByondSkinMacro = {
+ command: string;
+ name: string;
+};
+
export const setupHotKeys = () => {
// Read macros
- Byond.winget('default.*').then(data => {
+ Byond.winget('default.*').then((data: Record) => {
// Group each macro by ref
- const groupedByRef = {};
+ const groupedByRef: Record = {};
for (let key of Object.keys(data)) {
const keyPath = key.split('.');
const ref = keyPath[1];
const prop = keyPath[2];
if (ref && prop) {
+ // This piece of code imperatively adds each property to a
+ // ByondSkinMacro object in the order we meet it, which is hard
+ // to express safely in typescript.
if (!groupedByRef[ref]) {
- groupedByRef[ref] = {};
+ groupedByRef[ref] = {} as any;
}
groupedByRef[ref][prop] = data[key];
}
}
// Insert macros
const escapedQuotRegex = /\\"/g;
- const unescape = str => str
+ // prettier-ignore
+ const unescape = (str: string) => str
.substring(1, str.length - 1)
.replace(escapedQuotRegex, '"');
for (let ref of Object.keys(groupedByRef)) {
@@ -165,7 +185,36 @@ export const setupHotKeys = () => {
globalEvents.on('window-blur', () => {
releaseHeldKeys();
});
- globalEvents.on('key', key => {
+ 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) => {
+ 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 000000000000..17ba6678a6ec
--- /dev/null
+++ b/tgui/packages/tgui/http.ts
@@ -0,0 +1,17 @@
+/**
+ * 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 c333f8f2516a..d96fc98552c6 100644
--- a/tgui/packages/tgui/index.js
+++ b/tgui/packages/tgui/index.js
@@ -58,20 +58,11 @@ const setupApp = () => {
setupHotKeys();
captureExternalLinks();
- // Subscribe for state updates
+ // Re-render UI on store updates
store.subscribe(renderApp);
- // Dispatch incoming messages
- window.update = msg => store.dispatch(Byond.parseJson(msg));
-
- // Process the early update queue
- while (true) {
- const msg = window.__updateQueue__.shift();
- if (!msg) {
- break;
- }
- window.update(msg);
- }
+ // Dispatch incoming messages as store actions
+ Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
// Enable hot module reloading
if (module.hot) {
diff --git a/tgui/packages/tgui/interfaces/AirAlarm.js b/tgui/packages/tgui/interfaces/AirAlarm.js
index bc80011ad6f9..0852fd641e48 100644
--- a/tgui/packages/tgui/interfaces/AirAlarm.js
+++ b/tgui/packages/tgui/interfaces/AirAlarm.js
@@ -230,17 +230,22 @@ const AirAlarmControlModes = (props, context) => {
if (!modes || modes.length === 0) {
return 'Nothing to show';
}
- return modes.map(mode => (
-
- act('mode', { mode: mode.mode })} />
-
-
- ));
+ return (
+ <>
+ {modes.map((mode) => (
+
+ act('mode', { mode: mode.mode })}
+ />
+
+
+ ))}
+ >
+ );
};
diff --git a/tgui/packages/tgui/interfaces/Cargo.js b/tgui/packages/tgui/interfaces/Cargo.js
index d17c8119af46..205c3a50cd54 100644
--- a/tgui/packages/tgui/interfaces/Cargo.js
+++ b/tgui/packages/tgui/interfaces/Cargo.js
@@ -1,4 +1,3 @@
-import { toArray } from 'common/collections';
import { useBackend, useSharedState } from '../backend';
import { AnimatedNumber, Box, Button, Flex, LabeledList, Section, Table, Tabs } from '../components';
import { formatMoney } from '../format';
@@ -128,11 +127,10 @@ const CargoStatus = (props, context) => {
export const CargoCatalog = (props, context) => {
const { express } = props;
const { act, data } = useBackend(context);
- const {
- self_paid,
- app_cost,
- } = data;
- const supplies = toArray(data.supplies);
+
+ const { self_paid, app_cost } = data;
+
+ const supplies = Object.values(data.supplies);
const [
activeSupplyName,
setActiveSupplyName,
diff --git a/tgui/packages/tgui/interfaces/JobSelection.tsx b/tgui/packages/tgui/interfaces/JobSelection.tsx
index d19425671b5f..9c134c21a3c7 100644
--- a/tgui/packages/tgui/interfaces/JobSelection.tsx
+++ b/tgui/packages/tgui/interfaces/JobSelection.tsx
@@ -63,7 +63,7 @@ export const JobEntry: SFC<{
'font-size': '1.1rem',
'cursor': job.unavailable_reason ? 'initial' : 'pointer',
}}
- tooltip={
+ /* tooltip={
job.unavailable_reason ||
(job.prioritized ? (
<>
@@ -75,7 +75,7 @@ export const JobEntry: SFC<{
) : (
job.description
))
- }
+ } */
onClick={() => {
!job.unavailable_reason && data.onClick();
}}>
@@ -127,7 +127,7 @@ export const JobSelection = (props, context) => {
style={{ 'position': 'absolute', 'right': '1em' }}
onClick={() => act('select_job', { 'job': 'Random' })}
content="Random Job!"
- tooltip="Roll target random job. You can re-roll or cancel your random job if you don't like it."
+ // tooltip="Roll target random job. You can re-roll or cancel your random job if you don't like it."
/>
>
}
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx
new file mode 100644
index 000000000000..c4cf27f812d4
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/AntagsPage.tsx
@@ -0,0 +1,202 @@
+import { binaryInsertWith } from "common/collections";
+import { classes } from "common/react";
+import { useBackend, useLocalState } from "../../backend";
+import { Box, Button, Divider, Flex, Section, Stack, Tooltip } from "../../components";
+import { Antagonist, Category } from "./antagonists/base";
+import { PreferencesMenuData } from "./data";
+
+const requireAntag = require.context("./antagonists/antagonists", false, /.ts$/);
+
+const antagsByCategory = new Map();
+
+// This will break at priorities higher than 10, but that almost definitely
+// will not happen.
+const binaryInsertAntag = binaryInsertWith((antag: Antagonist) => {
+ return `${antag.priority}_${antag.name}`;
+});
+
+for (const antagKey of requireAntag.keys()) {
+ const antag = requireAntag<{
+ default?: Antagonist,
+ }>(antagKey).default;
+
+ if (!antag) {
+ continue;
+ }
+
+ antagsByCategory.set(
+ antag.category,
+ binaryInsertAntag(
+ antagsByCategory.get(antag.category) || [],
+ antag,
+ )
+ );
+}
+
+const AntagSelection = (props: {
+ antagonists: Antagonist[],
+ name: string,
+}, context) => {
+ const { act, data } = useBackend(context);
+ const className = "PreferencesMenu__Antags__antagSelection";
+
+ const [predictedState, setPredictedState]
+ = useLocalState(
+ context,
+ "AntagSelection_predictedState",
+ new Set(data.selected_antags),
+ );
+
+ const enableAntags = (antags: string[]) => {
+ const newState = new Set(predictedState);
+
+ for (const antag of antags) {
+ newState.add(antag);
+ }
+
+ setPredictedState(newState);
+
+ act("set_antags", {
+ antags,
+ toggled: true,
+ });
+ };
+
+ const disableAntags = (antags: string[]) => {
+ const newState = new Set(predictedState);
+
+ for (const antag of antags) {
+ newState.delete(antag);
+ }
+
+ setPredictedState(newState);
+
+ act("set_antags", {
+ antags,
+ toggled: false,
+ });
+ };
+
+ const antagonistKeys = props.antagonists.map(antagonist => antagonist.key);
+
+ return (
+
+ enableAntags(antagonistKeys)}
+ >
+ Enable All
+
+
+ disableAntags(antagonistKeys)}
+ >
+ Disable All
+
+ >
+ )}>
+
+ {props.antagonists.map(antagonist => {
+ const isBanned = data.antag_bans
+ && data.antag_bans.indexOf(antagonist.key) !== -1;
+
+ const daysLeft
+ = (data.antag_days_left && data.antag_days_left[antagonist.key])
+ || 0;
+
+ return (
+ 0)
+ ? "banned"
+ : predictedState.has(antagonist.key) ? "on" : "off"
+ }`,
+ ])}
+ key={antagonist.key}
+ >
+
+
+ {antagonist.name}
+
+
+
+ {
+ return (
+
+ {text}
+ {
+ index !== antagonist.description.length - 1
+ &&
+ }
+
+ );
+ })
+ } position="bottom">
+ {
+ if (isBanned) {
+ return;
+ }
+
+ if (predictedState.has(antagonist.key)) {
+ disableAntags([antagonist.key]);
+ } else {
+ enableAntags([antagonist.key]);
+ }
+ }}
+ >
+
+
+ {isBanned && (
+
+ )}
+
+ {daysLeft > 0 && (
+
+ {daysLeft} days left
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export const AntagsPage = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx
new file mode 100644
index 000000000000..e25bec52472c
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreferenceWindow.tsx
@@ -0,0 +1,164 @@
+import { exhaustiveCheck } from "common/exhaustive";
+import { useBackend, useLocalState } from "../../backend";
+import { Button, Stack } 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";
+
+enum Page {
+ Antags,
+ Main,
+ Jobs,
+ Species,
+ Quirks,
+}
+
+const CharacterProfiles = (props: {
+ activeSlot: number,
+ onClick: (index: number) => void,
+ profiles: (string | null)[],
+}) => {
+ const { profiles } = props;
+
+ return (
+
+ {profiles.map((profile, slot) => (
+
+ {
+ props.onClick(slot);
+ }} fluid>{profile ?? "New Character"}
+
+
+ ))}
+
+ );
+};
+
+export const CharacterPreferenceWindow = (props, context) => {
+ const { act, data } = useBackend(context);
+
+ const [currentPage, setCurrentPage] = useLocalState(context, "currentPage", 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;
+ default:
+ exhaustiveCheck(currentPage);
+ }
+
+ return (
+
+
+
+
+ {
+ act("change_slot", {
+ slot: slot + 1,
+ });
+ }}
+ profiles={data.character_profiles}
+ />
+
+
+ {!data.content_unlocked && (
+
+ Support the server by donating to unlock more slots!
+
+ )}
+
+
+
+
+
+
+
+ Character
+
+
+
+
+
+ {/*
+ Fun fact: This isn't "Jobs" so that it intentionally
+ catches your eyes, because it's really important!
+ */}
+
+ Occupations
+
+
+
+
+
+ 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 000000000000..ab27d2fe2f54
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/CharacterPreview.tsx
@@ -0,0 +1,15 @@
+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 000000000000..4bddd3305edb
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferenceWindow.tsx
@@ -0,0 +1,77 @@
+import { Stack } 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";
+
+export const GamePreferenceWindow = (props: {
+ startingPage?: GamePreferencesSelectedPage,
+}, context) => {
+ const { act, data } = useBackend(context);
+
+ const [currentPage, setCurrentPage]
+ = useLocalState(
+ context,
+ "currentPage",
+ props.startingPage ?? GamePreferencesSelectedPage.Settings,
+ );
+
+ let pageContents;
+
+ switch (currentPage) {
+ case GamePreferencesSelectedPage.Keybindings:
+ pageContents = ;
+ break;
+ case GamePreferencesSelectedPage.Settings:
+ pageContents = ;
+ break;
+ default:
+ exhaustiveCheck(currentPage);
+ }
+
+
+ return (
+
+
+
+
+
+
+
+ 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 000000000000..7e0245ed7661
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/GamePreferencesPage.tsx
@@ -0,0 +1,102 @@
+import { binaryInsertWith, sortBy } from "common/collections";
+import { InfernoNode } from "inferno";
+import { useBackend } from "../../backend";
+import { Box, Flex, Tooltip } from "../../components";
+import { PreferencesMenuData } from "./data";
+import features from "./preferences/features";
+import { FeatureValueInput } from "./preferences/features/base";
+import { TabbedMenu } from "./TabbedMenu";
+
+type PreferenceChild = {
+ name: string,
+ children: InfernoNode,
+};
+
+const binaryInsertPreference = binaryInsertWith(
+ (child) => child.name,
+);
+
+const sortByName = sortBy<[string, PreferenceChild[]]>(([name]) => name);
+
+export const GamePreferencesPage = (props, context) => {
+ const { act, data } = useBackend(context);
+
+ 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";
+
+ gamePreferences[category]
+ = binaryInsertPreference(gamePreferences[category] || [], entry);
+ }
+
+ const gamePreferenceEntries: [string, InfernoNode][] = sortByName(
+ Object.entries(gamePreferences)
+ ).map(
+ ([category, preferences]) => {
+ return [category, preferences.map(entry => entry.children)];
+ });
+
+ return (
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx
new file mode 100644
index 000000000000..9dcf139fdfee
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/JobsPage.tsx
@@ -0,0 +1,437 @@
+import { sortBy } from "common/collections";
+import { classes } from "common/react";
+import { InfernoNode, SFC } from "inferno";
+import { useBackend } from "../../backend";
+import { Box, Button, Dropdown, Stack, 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 PRIORITY_BUTTON_SIZE = "18px";
+
+const PriorityButton = (props: {
+ name: string,
+ color: 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 PriorityHeaders = () => {
+ const className = "PreferencesMenu__Jobs__PriorityHeader";
+
+ return (
+
+
+
+
+ Off
+
+
+
+ Low
+
+
+
+ Medium
+
+
+
+ High
+
+
+ );
+};
+
+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 { act, data } = useBackend(context);
+ const { className, job, name } = props;
+
+ const isOverflow = data.overflow_role === name;
+ const priority = data.job_preferences[name];
+ const selected = data.job_alt_titles[name] || 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 (
+
+
+
+
+
+ (act("set_alt_title", {
+ job: name,
+ alt_title: selected,
+ }))
+ }
+ />
+
+
+
+
+ {rightSide}
+
+
+
+ );
+};
+
+const Department: 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}
+
+ }
+ />
+
+ );
+};
+
+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 000000000000..24ad1946d2c4
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/KeybindingsPage.tsx
@@ -0,0 +1,472 @@
+import { Component } from "inferno";
+import { Box, Button, KeyListener, Stack, Tooltip, TrackOutsideClicks } 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"];
+
+ /**
+ * 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,
+ };
+
+ 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,
+ 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();
+ }
+
+ 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 {
+ 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"));
+ const keybindingsData: Keybindings = await keybindingsResponse.json();
+
+ 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() {
+ 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/MainPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx
new file mode 100644
index 000000000000..a9a4009d947a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx
@@ -0,0 +1,610 @@
+import { classes } from "common/react";
+import { sendAct, useBackend, useLocalState } from "../../backend";
+import { Autofocus, Box, Button, Flex, LabeledList, Popper, Stack, TrackOutsideClicks } 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";
+
+const CLOTHING_CELL_SIZE = 48;
+const CLOTHING_SIDEBAR_ROWS = 9;
+
+const CLOTHING_SELECTION_CELL_SIZE = 48;
+const CLOTHING_SELECTION_WIDTH = 5.4;
+const CLOTHING_SELECTION_MULTIPLIER = 5.2;
+
+const CharacterControls = (props: {
+ handleRotate: () => void,
+ handleOpenSpecies: () => void,
+ handleCycleBackground: () => void,
+ gender: Gender,
+ setGender: (gender: Gender) => void,
+ showGender: boolean,
+}) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {props.showGender && (
+
+
+
+ )}
+
+ );
+};
+
+const ChoicedSelection = (props: {
+ name: string,
+ catalog: FeatureChoicedServerData,
+ selected: string,
+ supplementalFeature?: string,
+ supplementalValue?: unknown,
+ onClose: () => void,
+ onSelect: (value: string) => void,
+}, context) => {
+ const { act } = useBackend(context);
+
+ const {
+ catalog,
+ supplementalFeature,
+ supplementalValue,
+ } = props;
+
+ if (!catalog.icons) {
+ return (
+
+ Provided catalog had no icons!
+
+ );
+ }
+
+ return (
+
+
+
+
+ {supplementalFeature && (
+
+
+
+ )}
+
+
+
+ Select {props.name.toLowerCase()}
+
+
+
+
+
+ X
+
+
+
+
+
+
+
+
+ {Object.entries(catalog.icons).map(([name, image], index) => {
+ return (
+
+ {
+ props.onSelect(name);
+ }}
+ selected={name === props.selected}
+ tooltip={name}
+ tooltipPosition="right"
+ style={{
+ height: `${CLOTHING_SELECTION_CELL_SIZE}px`,
+ width: `${CLOTHING_SELECTION_CELL_SIZE}px`,
+ }}
+ >
+
+
+
+ );
+ })}
+
+
+
+
+
+ );
+};
+
+const GenderButton = (props: {
+ handleSetGender: (gender: Gender) => void,
+ gender: Gender,
+}, context) => {
+ const [genderMenuOpen, setGenderMenuOpen] = useLocalState(context, "genderMenuOpen", false);
+
+ return (
+
+ {[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;
+
+ return (
+
+
+
+ )}>
+ {
+ if (isOpen) {
+ handleClose();
+ } 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}
+ />)}
+
+
+ );
+};
+
+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.random_body
+ !== 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["random_name"];
+ }
+
+ 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");
+ }}
+ handleCycleBackground={() => {
+ act("cycle");
+ }}
+ 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 000000000000..8ac3de359759
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/PageButton.tsx
@@ -0,0 +1,30 @@
+import { 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 000000000000..c697bedf7e6f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/QuirksPage.tsx
@@ -0,0 +1,339 @@
+import { StatelessComponent } from "inferno";
+import { Box, Icon, Stack, Tooltip } from "../../components";
+import { PreferencesMenuData, Quirk } from "./data";
+import { useBackend, useLocalState } from "../../backend";
+import { ServerPreferencesFetcher } from "./ServerPreferencesFetcher";
+
+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";
+
+ const child = (
+ {
+ props.onClick(quirkKey, quirk);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {quirk.name}
+
+
+
+ {quirk.value}
+
+
+
+
+
+ {quirk.description}
+
+
+
+
+
+ );
+
+ if (quirk.failTooltip) {
+ return (
+
+ {child}
+
+ );
+ } else {
+ return child;
+ }
+ })}
+
+ );
+};
+
+const StatDisplay: 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];
+
+ const { data } = useBackend(context);
+ const lock_reason = data.locked_quirks[quirkName];
+ if (lock_reason)
+ {
+ return lock_reason;
+ }
+
+ if (quirk.mood && !data.mood_enabled)
+ {
+ return "This quirk requires mood to be enabled in your game options.";
+ }
+
+ 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 000000000000..ea9a4a64a604
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx
@@ -0,0 +1,64 @@
+import { Dropdown, Icon } from "../../components";
+import { RandomSetting } from "./data";
+import { exhaustiveCheck } from "common/exhaustive";
+
+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;
+ default:
+ exhaustiveCheck(value);
+ }
+
+ 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}
+ width="auto"
+ />
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx
new file mode 100644
index 000000000000..d21cff506ac5
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/ServerPreferencesFetcher.tsx
@@ -0,0 +1,38 @@
+import { Component, InfernoNode } from "inferno";
+import { resolveAsset } from "../../assets";
+import { fetchRetry } from "../../http";
+import { ServerData } from "./data";
+
+// Cache response so it's only sent once
+let fetchServerData: Promise | undefined;
+
+export class ServerPreferencesFetcher extends Component<{
+ render: (serverData: ServerData | undefined) => InfernoNode,
+}, {
+ serverData?: ServerData;
+}> {
+ state = {
+ serverData: undefined,
+ };
+
+ componentDidMount() {
+ this.populateServerData();
+ }
+
+ async populateServerData() {
+ if (!fetchServerData) {
+ fetchServerData = fetchRetry(resolveAsset("preferences.json"))
+ .then(response => response.json());
+ }
+
+ const preferencesData: ServerData = await fetchServerData;
+
+ this.setState({
+ serverData: preferencesData,
+ });
+ }
+
+ render() {
+ return this.props.render(this.state.serverData);
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx
new file mode 100644
index 000000000000..c864c3693246
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/SpeciesPage.tsx
@@ -0,0 +1,363 @@
+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.Nuts]: "seedling",
+ [Food.Raw]: "drumstick-bite",
+ [Food.Seafood]: "fish",
+ [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.Nuts]: "Nuts",
+ [Food.Raw]: "Raw",
+ [Food.Seafood]: "Seafood",
+ [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];
+
+ return (
+
+
+
+
+
+
+
+
+
+ {species.map(([speciesKey, species]) => {
+ return (
+ setSpecies(speciesKey)}
+ selected={
+ data.character_preferences.misc.species === speciesKey
+ }
+ tooltip={species.name}
+ style={{
+ display: "block",
+ height: "64px",
+ width: "64px",
+ }}
+ >
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ )
+ }>
+
+
+ {currentSpecies.desc}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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 000000000000..267f624c19f4
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/TabbedMenu.tsx
@@ -0,0 +1,94 @@
+import { Component, createRef, InfernoNode, RefObject } from "inferno";
+import { Button, Section, Stack } from "../../components";
+import { FlexProps } from "../../components/Flex";
+
+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.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 (
+
+
+
+ );
+ }
+ )}
+
+
+
+ );
+ }
+}
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/abductor.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/abductor.ts
new file mode 100644
index 000000000000..9f0abe6da87a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/abductor.ts
@@ -0,0 +1,24 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+const Abductor: Antagonist = {
+ key: "abductor",
+ name: "Abductor",
+ description: [
+ multiline`
+ Abductors are technologically advanced alien society set on cataloging
+ all species in the system. Unfortunately for their subjects their methods
+ are quite invasive.
+ `,
+
+ multiline`
+ 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.
+ `,
+ ],
+ category: Category.Midround,
+};
+
+export default Abductor;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/blob.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/blob.ts
new file mode 100644
index 000000000000..1d2de66ab481
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/blob.ts
@@ -0,0 +1,20 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const BLOB_MECHANICAL_DESCRIPTION = multiline`
+ The blob infests the station and destroys everything in its path, including
+ hull, fixtures, and creatures. 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!
+`;
+
+const Blob: Antagonist = {
+ key: "blob",
+ name: "Blob",
+ description: [
+ BLOB_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+};
+
+export default Blob;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/bloodbrother.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/bloodbrother.ts
new file mode 100644
index 000000000000..89ca09a6e74b
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/bloodbrother.ts
@@ -0,0 +1,26 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const BLOODBROTHER_MECHANICAL_DESCRIPTION
+ = multiline`
+ Complete a set of traitor objectives with a single ally using only makeshift weaponry and what you two can scavenge.
+ Win or lose together.
+ `;
+
+
+const BloodBrother: Antagonist = {
+ key: "bloodbrother",
+ name: "Blood Brother",
+ description: [
+ multiline`
+ The courses and tribulations you've overcome have been arduous. Now comes your final test; complete the objectives expected from a trained, equipped agent.
+ You will be given no codewords and no uplink.
+ Only another trainee, and your fates will be the same.
+ Good luck.
+ `,
+ BLOODBROTHER_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+};
+
+export default BloodBrother;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/bloodsucker.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/bloodsucker.ts
new file mode 100644
index 000000000000..fd3a13a34c80
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/bloodsucker.ts
@@ -0,0 +1,25 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const BLOODSUCKER_MECHANICAL_DESCRIPTION
+ = multiline`
+ Develop your own power and establish Vassals to accomplish your goals.
+ Avoid Sol's harsh rays, siphon blood, and maintain the Masquerade to excel.
+ `;
+
+const Bloodsucker: Antagonist = {
+ key: "bloodsucker",
+ name: "Bloodsucker",
+ description: [
+ multiline`
+ You are descended from the cursed blood of Cain, the first Kindred.
+ As an ambitious, upstart vampire, you seek to raise your influence through exploits on a newly-founded Nanotrasen station.
+ The Camarilla clan has their eyes on you; perform well.
+ `,
+ BLOODSUCKER_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default Bloodsucker;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/changeling.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/changeling.ts
new file mode 100644
index 000000000000..91c51e51bc46
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/changeling.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+const Changeling: Antagonist = {
+ key: "changeling",
+ name: "Changeling",
+ description: [
+ multiline`
+ We celebrate this day! Another's misfortune is our bounty, and we have found a suitable form.
+ They bring to us memories, responsibilities, and opportunities. We shall continue our normal schedule, but we must feed.
+ Surely a familiar face will lower their trust?
+ `,
+
+ multiline`
+ Assume the guise of a crew member as a DNA-absorbing slug.
+ Use unnatural powers and resilience to absorb further victims or other changelings and complete your objectives.
+ `,
+ ],
+ category: Category.Roundstart,
+};
+
+export default Changeling;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/clownoperative.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/clownoperative.ts
new file mode 100644
index 000000000000..8261e7a9b0f1
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/clownoperative.ts
@@ -0,0 +1,26 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const CLOWNOP_MECHANICAL_DESCRIPTION
+ = multiline`
+ Plan an attack on the station in this parallel to Nuclear Operative.
+ Utilize clown technology and specialized gear to best the crew, secure the disk, and detonate the device.
+ `;
+
+
+const ClownOperative: Antagonist = {
+ key: "clownoperative",
+ name: "Clown Operative",
+ description: [
+ multiline`
+ HONK! The operation is simple. Move in. Prank.
+ Detonate the bananium device and summon the Honkmother.
+ The Clown Planet depends on you!
+ `,
+
+ CLOWNOP_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+};
+
+export default ClownOperative;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/cultist.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/cultist.ts
new file mode 100644
index 000000000000..db22def16301
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/cultist.ts
@@ -0,0 +1,19 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+const Cultist: Antagonist = {
+ key: "cultist",
+ name: "Cultist",
+ description: [
+ multiline`
+ It begins as a pulsing of your veins. The blood quickens, then grows bright and then dark. You will kill in the name of the Geometer, but you will also beckon more into her crimson flock. Most of all, you will see her returned beyond the Veil. Here, it is weakened. Now, you will act.
+ `,
+
+ multiline`
+ Conspire with other blood cultists to convert other crew and take over the station. Track down your sacrifice target, kill them via ritual, then call upon Nar-Sie where the Veil is weak. Defend her crystals until she is ready, and witness her glory rip into our dimension.
+ `,
+ ],
+ category: Category.Roundstart,
+};
+
+export default Cultist;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/darkspawn.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/darkspawn.ts
new file mode 100644
index 000000000000..3ab2aaf15802
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/darkspawn.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const DARKSPAWN_MECHANICAL_DESCRIPTION
+ = multiline`
+ Finish what you and your fellow kin started eons ago. Ascend to godhood with two others by harvesting the sparks of the infirm. The sigils hunger, and you must respond.
+ `;
+
+const Darkspawn: Antagonist = {
+ key: "darkspawn",
+ name: "Darkspawn",
+ description: [
+ multiline`
+ It’s hard to recall what you once were. Star cycles passed again and again as you slumbered in the Void. Eventually, the emptiness touched something. You fabricated a shoddy disguise from your first victim, and now countless minds tug at your attention.
+ `,
+ DARKSPAWN_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default Darkspawn;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/demonofsin.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/demonofsin.ts
new file mode 100644
index 000000000000..97ab9a36be39
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/demonofsin.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const DEMONOFSIN_MECHANICAL_DESCRIPTION
+ = multiline`
+ IN PROGRESS
+ `;
+
+const DemonOfSin: Antagonist = {
+ key: "demonofsin",
+ name: "Demon of Sin",
+ description: [
+ multiline`
+ IN PROGRESS
+ `,
+ DEMONOFSIN_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+ priority: -1,
+};
+
+export default DemonOfSin;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/devil.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/devil.ts
new file mode 100644
index 000000000000..1ce163527024
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/devil.ts
@@ -0,0 +1,25 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const DEVIL_MECHANICAL_DESCRIPTION
+ = multiline`
+ Make deals with crew members to slowly accrue power, but be wary of revealing your weaknesses.
+ As you collect more contracts, your disguise will fall away, and the mortals will grow desperate in their pursuit to banish you.
+ `;
+
+const Devil: Antagonist = {
+ key: "devil",
+ name: "Devil",
+ description: [
+ multiline`
+ The Fallen Angel, Lucifer, is a fine friend to those down on fortune, and you are one of his many agents.
+ While you can take several forms, you don one of a corporate worker to infiltrate a pit of despondency.
+ The poor souls have little to live for; temptation should prove simple.
+ `,
+ DEVIL_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default Devil;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/disease.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/disease.ts
new file mode 100644
index 000000000000..6a7d06ed487c
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/disease.ts
@@ -0,0 +1,16 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+const SentientDisease: Antagonist = {
+ key: "disease",
+ name: "Sentient Disease",
+ description: [
+ multiline`
+ Mutate and spread yourself and infect as much of the crew as possible
+ with a deadly plague of your own creation.
+ `,
+ ],
+ category: Category.Midround,
+};
+
+export default SentientDisease;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/fugitive.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/fugitive.ts
new file mode 100644
index 000000000000..88b7f6a0dbcd
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/fugitive.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const FUGITIVE_MECHANICAL_DESCRIPTION
+ = multiline`Avoid the fugitive hunters sent after you with any allies if you have them, and make sure you stay free by the time the shift ends.
+ `;
+
+const Fugitive: Antagonist = {
+ key: "fugitive",
+ name: "Fugitive",
+ description: [
+ multiline`
+ Freedom. You gained it somehow, but you're not about to give it up.
+ You're being pursued, and the only friends are those that might be with you.
+ It's unclear who they'll send after you, but a bounty'll be claimed, whether you're dead or alive.
+ `,
+ FUGITIVE_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+};
+
+export default Fugitive;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/gangster.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/gangster.ts
new file mode 100644
index 000000000000..728cfd039fe1
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/gangster.ts
@@ -0,0 +1,19 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const GANGSTER_MECHANICAL_DESCRIPTION
+ = multiline`
+ Convince people to join your family, wear your uniform, tag turf
+ for the family, and accomplish your family's goals.
+ `;
+
+const Gangster: Antagonist = {
+ key: "gangster",
+ name: "Gang Leader",
+ description: [
+ GANGSTER_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+};
+
+export default Gangster;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/headrevolutionary.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/headrevolutionary.ts
new file mode 100644
index 000000000000..ea670d854dfa
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/headrevolutionary.ts
@@ -0,0 +1,20 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const REVOLUTIONARY_MECHANICAL_DESCRIPTION
+ = multiline`
+ Armed with a flash, convert as many people to the revolution as you can.
+ Kill or exile all heads of staff on the station.
+ `;
+
+const HeadRevolutionary: Antagonist = {
+ key: "headrevolutionary",
+ name: "Head Revolutionary",
+ description: [
+ "VIVA LA REVOLUTION!",
+ REVOLUTIONARY_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+};
+
+export default HeadRevolutionary;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/heretic.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/heretic.ts
new file mode 100644
index 000000000000..611ad714eda8
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/heretic.ts
@@ -0,0 +1,24 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const HERETIC_MECHANICAL_DESCRIPTION
+ = multiline`
+ Find hidden influences and sacrifice crew members to gain magical
+ powers and ascend as one of several paths.
+ `;
+
+const Heretic: Antagonist = {
+ key: "heretic",
+ name: "Heretic",
+ description: [
+ multiline`
+ Forgotten, devoured, gutted. Humanity has forgotten the eldritch forces
+ of decay, but the mansus veil has weakened. We will make them taste fear
+ again...
+ `,
+ HERETIC_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+};
+
+export default Heretic;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/hivemindhost.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/hivemindhost.ts
new file mode 100644
index 000000000000..20ae560d820a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/hivemindhost.ts
@@ -0,0 +1,24 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const HIVEMINDHOST_MECHANICAL_DESCRIPTION
+ = multiline`
+ Silently induct crew members into your neural network, and gain more abilities as you do so.
+ Avoid the ire of security or other hosts, and create one consciousness for all thinking things.
+ `;
+
+const HivemindHost: Antagonist = {
+ key: "hivemindhost",
+ name: "Hivemind Host",
+ description: [
+ multiline`
+ Minds are delicious things. You wander and find and touch, leaving the meat-sacks none the wiser. For now, you can instruct them, but the One Mind will unite and elevate all.
+ We shall become Hivemind.
+ `,
+ HIVEMINDHOST_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default HivemindHost;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/horror.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/horror.ts
new file mode 100644
index 000000000000..720d384b6b9b
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/horror.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const ELDRITCHHORROR_MECHANICAL_DESCRIPTION
+ = multiline`
+ IN PROGRESS
+ `;
+
+const EldritchHorror: Antagonist = {
+ key: "eldritchhorror",
+ name: "Eldritch Horror",
+ description: [
+ multiline`
+ IN PROGRESS
+ `,
+ ELDRITCHHORROR_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+ priority: -1,
+};
+
+export default EldritchHorror;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/infiltrator.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/infiltrator.ts
new file mode 100644
index 000000000000..cf3482c3896d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/infiltrator.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const INFILTRATOR_MECHANICAL_DESCRIPTION
+ = multiline`
+ With the rest of your squad, gear up and covertly infiltrate the ranks of the station from your external shuttle. Work together to sabotage, kidnap, subvert, and steal.
+ `;
+
+const Infiltrator: Antagonist = {
+ key: "infiltrator",
+ name: "Syndicate Infiltrator",
+ description: [
+ multiline`
+ It's been quiet on the moon base ever since your team moved in to keep tabs on the nearby Nanotrasen station. Nothing you observe provokes the brief transmission that comes in one day. The objectives are simple, and to be done as quietly as possible.
+ `,
+ INFILTRATOR_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default Infiltrator;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/internalaffairsagent.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/internalaffairsagent.ts
new file mode 100644
index 000000000000..9027af0e0186
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/internalaffairsagent.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const IAA_MECHANICAL_DESCRIPTION
+ = multiline`
+ Minimize damage to Nanotrasen civilians and their station, but utilize an uplink to eliminate all rogue agents. Be wary; others will be moving against you.
+ `;
+
+const internalaffairsagent: Antagonist = {
+ key: "internalaffairsagent",
+ name: "Internal Affairs Agent",
+ description: [
+ multiline`
+ Someone's ratted. The trust of your agent cell is in question. Presume any and all confirmed targets dangerous, and watch your own back, operative. Clean up this mess and await further instructions.
+ `,
+ IAA_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default internalaffairsagent;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/malfai.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/malfai.ts
new file mode 100644
index 000000000000..ed6b5d7f9230
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/malfai.ts
@@ -0,0 +1,20 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const MALF_AI_MECHANICAL_DESCRIPTION
+ = multiline`
+ 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.
+ `;
+
+const MalfAI: Antagonist = {
+ key: "malfai",
+ name: "Malfunctioning AI",
+ description: [
+ MALF_AI_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+};
+
+export default MalfAI;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/monkey.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/monkey.ts
new file mode 100644
index 000000000000..b4bfa5502e6d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/monkey.ts
@@ -0,0 +1,24 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const MONKEY_MECHANICAL_DESCRIPTION
+ = multiline`
+ Enter the station infected with a disease that will eventually turn you into a monkey. Bite others to spread the virus, and escape to Centcom to continue the cycle.
+ `;
+
+const Monkey: Antagonist = {
+ key: "monkey",
+ name: "Monkey",
+ description: [
+ multiline`
+ You thought it'd be an ordinary day, but ever since a monkey bit you the day before, you've felt yourself regressing into something more base.
+ When you look at your colleagues, all you can think of is sinking your teeth into them and flailing wildly.
+
+ `,
+ MONKEY_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default Monkey;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/monsterhunter.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/monsterhunter.ts
new file mode 100644
index 000000000000..3baeecb95927
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/monsterhunter.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const MONSTERHUNTER_MECHANICAL_DESCRIPTION
+ = multiline`
+ IN PROGRESS
+ `;
+
+const MonsterHunter: Antagonist = {
+ key: "monsterhunter",
+ name: "Monster Hunter",
+ description: [
+ multiline`
+ IN PROGRESS
+ `,
+ MONSTERHUNTER_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+ priority: -1,
+};
+
+export default MonsterHunter;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/nightmare.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/nightmare.ts
new file mode 100644
index 000000000000..6be1987bd184
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/nightmare.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const NIGHTMARE_MECHANICAL_DESCRIPTION
+ = multiline`
+ You've been pulled from a nightmare by the brightness. Though your corpse is mangled, it still serves as an agile weapon. Snuff the light, so you may return to the eternal dream without answers.
+ `;
+
+
+const Nightmare: Antagonist = {
+ key: "nightmare",
+ name: "Nightmare",
+ description: [
+ multiline`
+ It's just you. You reach, and the light BURNS. You call, to no response, and the STARS fill your eyes and SEAR your twisted carapace. THE RAYS BLIND AND BREAK AND BECKON.
+ `,
+ NIGHTMARE_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+};
+
+export default Nightmare;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/obsessed.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/obsessed.ts
new file mode 100644
index 000000000000..b8937edad1c8
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/obsessed.ts
@@ -0,0 +1,23 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const OBSESSED_MECHANICAL_DESCRIPTION
+ = multiline`Avoid the fugitive hunters sent after you with any allies if you have them, and make sure you stay free by the time the shift ends.
+ `;
+
+const Obsessed: Antagonist = {
+ key: "obsessed",
+ name: "Obsessed",
+ description: [
+ multiline`
+ Beauty is often misunderstood. Fate even more so.
+ The others, your colleagues, they don't- they don't hear the voices that whisper sense.
+ Maybe someday, you'll hear them too, and you'll realize just how important you are.
+ Don't try to flee. You can't.
+ `,
+ OBSESSED_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+};
+
+export default Obsessed;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/operative.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/operative.ts
new file mode 100644
index 000000000000..582c74f003fb
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/operative.ts
@@ -0,0 +1,25 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const OPERATIVE_MECHANICAL_DESCRIPTION = multiline`
+ Retrieve the nuclear authentication disk, use it to activate the nuclear
+ fission explosive, and destroy the station.
+`;
+
+const Operative: Antagonist = {
+ key: "operative",
+ name: "Nuclear Operative",
+ description: [
+ multiline`
+ 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.
+ `,
+
+ OPERATIVE_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+};
+
+export default Operative;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/pirate.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/pirate.ts
new file mode 100644
index 000000000000..8c46ca53a25a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/pirate.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const PIRATE_MECHANICAL_DESCRIPTION
+ = multiline`
+ IN PROGRESS
+ `;
+
+const Pirate: Antagonist = {
+ key: "pirate",
+ name: "Pirate",
+ description: [
+ multiline`
+ IN PROGRESS
+ `,
+ PIRATE_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+ priority: -1,
+};
+
+export default Pirate;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/revenant.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/revenant.ts
new file mode 100644
index 000000000000..a56baf4a9fe0
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/revenant.ts
@@ -0,0 +1,25 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const REVENANT_MECHANICAL_DESCRIPTION
+ = multiline`
+ Stalk the halls of the station as a ghastly spirit and absorb the essence of the dead or isolated.
+ Avoid combat, as you are fragile.
+ `;
+
+const Revenant: Antagonist = {
+ key: "revenant",
+ name: "Revenant",
+ description: [
+ multiline`
+ Whispering, fleeting, agony.
+ Enough pain rolls up and you float over to respond, like flies to rotting meat.
+ You flit in, and you hear voices roll over you, and you find those lonely, clouded with despair.
+ Time to feast.
+ `,
+ REVENANT_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+};
+
+export default Revenant;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/sentientcreature.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/sentientcreature.ts
new file mode 100644
index 000000000000..3789c7a9c064
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/sentientcreature.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const SENTIENTCREATURE_MECHANICAL_DESCRIPTION
+ = multiline`
+ TBD
+ `;
+
+const SentientCreature: Antagonist = {
+ key: "sentientcreature",
+ name: "Sentient Creature",
+ description: [
+ multiline`
+ TBD
+ `,
+ SENTIENTCREATURE_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default SentientCreature;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/servantofratvar.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/servantofratvar.ts
new file mode 100644
index 000000000000..9c1107e61f53
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/servantofratvar.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const RATVAR_MECHANICAL_DESCRIPTION
+ = multiline`
+ Teleport onto the Nanotrasen station and subvert its power and its people to your own. Once the Ark is ready, defend it from the crew's assault. Purge all untruths and honor Ratvar.
+ `;
+
+const ServantOfRatvar: Antagonist = {
+ key: "servantofratvar",
+ name: "Servant of Ratvar",
+ description: [
+ multiline`
+ A flash of yellow light! The sound of whooshing steam and clanking cogs surrounds you, and you understand your mission. Ratvar, the Clockwork Justicar, has trusted you to secure the gateway in his Ark!
+ `,
+ RATVAR_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default ServantOfRatvar;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/shadowling.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/shadowling.ts
new file mode 100644
index 000000000000..455476852e00
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/shadowling.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const SHADOWLING_MECHANICAL_DESCRIPTION
+ = multiline`
+ Bend the foolish to your will, as you have perfected it. With two kin, establish enough thralls to exploit their psyches and ascend to godhood. Raise the foundation of a new empire, and prepare to conquer that which killed you before.
+ `;
+
+const Shadowling: Antagonist = {
+ key: "shadowling",
+ name: "Shadowling",
+ description: [
+ multiline`
+ Eons have gone by as you patiently sat in the Void, waiting. Plotting. You and your breathren are awakened at once, and your minds strike quickly. Donning false carapaces, you all move to exact your conniving plan.
+ `,
+ SHADOWLING_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default Shadowling;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spacedragon.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spacedragon.ts
new file mode 100644
index 000000000000..f4bebfcf50e6
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spacedragon.ts
@@ -0,0 +1,16 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+const SpaceDragon: Antagonist = {
+ key: "spacedragon",
+ name: "Space Dragon",
+ description: [
+ multiline`
+ Become a ferocious space dragon. Breathe fire, summon an army of space
+ carps, crush walls, and terrorize the station.
+ `,
+ ],
+ category: Category.Midround,
+};
+
+export default SpaceDragon;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spaceninja.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spaceninja.ts
new file mode 100644
index 000000000000..8c5074463375
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/spaceninja.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+const SpaceNinja: Antagonist = {
+ key: "spaceninja",
+ name: "Space Ninja",
+ description: [
+ multiline`
+ The flesh is imperfect. This... metal, this suit, is what humanity shall become.
+ It is the future. A contract comes in, and you answer.
+ Superiority must be established. Honor will be fulfilled.
+ `,
+
+ multiline`
+ Assist either the Syndicate or Nanotrasen as a hired, stealthy mercenary.
+ Fulfill your contract and flex technological prowess as you do so.
+ `,
+ ],
+ category: Category.Midround,
+};
+
+export default SpaceNinja;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/traitor.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/traitor.ts
new file mode 100644
index 000000000000..e7b06542da64
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/traitor.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const TRAITOR_MECHANICAL_DESCRIPTION
+ = multiline`
+ Utilize a hidden uplink to acquire equipment, then set out to accomplish your objectives how you see fit. Be cunning, be fierce, be swift.
+ `;
+
+const Traitor: Antagonist = {
+ key: "traitor",
+ name: "Traitor",
+ description: [
+ multiline`
+ Six words. You'd been instructed to remember them, whether it was to clear a favor, to claim vengeance, to make money, or whatever else drove you to the Syndicate. Maybe they were driven to you. A beep sounds in your headset.
+ `,
+ TRAITOR_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default Traitor;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/vampire.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/vampire.ts
new file mode 100644
index 000000000000..5e4383e13820
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/vampire.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const VAMPIRE_MECHANICAL_DESCRIPTION
+ = multiline`
+ Use powers of the night to complete your objectives. Suck blood from individuals to fuel your supernatural abilities and grow in power.
+ `;
+
+const Vampire: Antagonist = {
+ key: "vampire",
+ name: "Vampire",
+ description: [
+ multiline`
+ The gift of Lilith courses through you; you've never known such power before! But immortality carries a price, and the Thirst drives you to action. You've observed your colleagues for some weeks; it's time to feed.
+ `,
+ VAMPIRE_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+ priority: -1,
+};
+
+export default Vampire;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/wizard.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/wizard.ts
new file mode 100644
index 000000000000..0455235cb586
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/wizard.ts
@@ -0,0 +1,20 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const WIZARD_MECHANICAL_DESCRIPTION
+ = multiline`
+ Choose between a variety of powerful spells in order to cause chaos
+ among Space Station 13.
+ `;
+
+const Wizard: Antagonist = {
+ key: "wizard",
+ name: "Wizard",
+ description: [
+ `"GREETINGS. WE'RE THE WIZARDS OF THE WIZARD'S FEDERATION."`,
+ WIZARD_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Roundstart,
+};
+
+export default Wizard;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/xenomorph.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/xenomorph.ts
new file mode 100644
index 000000000000..82e75ce7d8e5
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/xenomorph.ts
@@ -0,0 +1,16 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+const Xenomorph: Antagonist = {
+ key: "xenomorph",
+ name: "Xenomorph",
+ description: [
+ multiline`
+ Become the extraterrestrial xenomorph. Start as a larva, and progress
+ your way up the caste, including even the Queen!
+ `,
+ ],
+ category: Category.Midround,
+};
+
+export default Xenomorph;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/zombie.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/zombie.ts
new file mode 100644
index 000000000000..13caea29f59d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/antagonists/zombie.ts
@@ -0,0 +1,22 @@
+import { Antagonist, Category } from "../base";
+import { multiline } from "common/string";
+
+export const ZOMBIE_MECHANICAL_DESCRIPTION
+ = multiline`
+ IN PROGRESS
+ `;
+
+const Zombie: Antagonist = {
+ key: "zombie",
+ name: "Zombie",
+ description: [
+ multiline`
+ IN PROGRESS
+ `,
+ ZOMBIE_MECHANICAL_DESCRIPTION,
+ ],
+ category: Category.Midround,
+ priority: -1,
+};
+
+export default Zombie;
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/base.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/base.ts
new file mode 100644
index 000000000000..a91b6af87813
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/antagonists/base.ts
@@ -0,0 +1,35 @@
+/**
+ * This folder represents the antagonists you can choose in the preferences
+ * menu.
+ *
+ * Every file in this folder represents one antagonist.
+ *
+ * For example "Syndicate Sleeper Agent" -> syndicatesleeperagent.ts
+ *
+ * "Antagonist" in this context actually means ruleset.
+ * This is an important distinction--it means that players can choose to be
+ * a roundstart traitor, but not a latejoin traitor.
+ *
+ * Icons are generated from the antag datums themselves, provided by the
+ * `antag_datum` variable on the /datum/dynamic_ruleset.
+ *
+ * The icon used is whatever the return value of get_preview_icon() is.
+ * Most antagonists, unless they want an especially cool effect, can simply
+ * set preview_outfit to some typepath representing their character.
+ */
+
+export type Antagonist = {
+ // the antag_flag, made lowercase, and with non-alphanumerics removed.
+ key: string;
+
+ name: string;
+ description: string[];
+ category: Category;
+ priority?: number;
+};
+
+export enum Category {
+ Roundstart,
+ Midround,
+ Latejoin,
+}
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts b/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts
new file mode 100644
index 000000000000..e5076c64b933
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/data.ts
@@ -0,0 +1,195 @@
+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",
+ Nuts = "NUTS",
+ Pineapple = "PINEAPPLE",
+ Raw = "RAW",
+ Seafood = "SEAFOOD",
+ 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[];
+
+ 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;
+ alt_titles: string[];
+};
+
+export type Quirk = {
+ description: string;
+ icon: string;
+ name: string;
+ value: number;
+ mood: BooleanLike;
+};
+
+export type QuirkInfo = {
+ max_positive_quirks: number;
+ quirk_info: Record;
+ quirk_blacklist: 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: {
+ random_body: RandomSetting,
+ [otherKey: string]: unknown;
+ };
+ secondary_features: Record;
+ supplemental_features: Record;
+
+ names: Record;
+
+ misc: {
+ gender: Gender;
+ joblessrole: JoblessRole;
+ species: string;
+ };
+
+ randomization: Record;
+ };
+
+ content_unlocked: number,
+ ckey: string,
+
+ job_bans?: string[];
+ job_days_left?: Record;
+ job_required_experience?: Record;
+ job_preferences: Record;
+ job_alt_titles: Record;
+
+ keybindings: Record;
+ overflow_role: string;
+ selected_quirks: string[];
+ locked_quirks: Record;
+ mood_enabled: BooleanLike;
+
+ antag_bans?: string[];
+ antag_days_left?: Record;
+ selected_antags: string[];
+
+ active_slot: number;
+ name_to_use: string;
+
+ earned_skillcapes: string[];
+
+ window: Window;
+};
+
+export type ServerData = {
+ jobs: {
+ departments: Record;
+ jobs: Record;
+ };
+ names: {
+ types: Record;
+ };
+ quirks: QuirkInfo,
+ 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 000000000000..f7b3f87c8a0a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/index.tsx
@@ -0,0 +1,24 @@
+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 000000000000..8f798394491f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/names.tsx
@@ -0,0 +1,237 @@
+import { binaryInsertWith, sortBy } from "common/collections";
+import { useLocalState } from "../../backend";
+import { Box, 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 (
+
+
+
+ )}
+ title="Alternate names"
+ >
+
+ {sortNameWithKeyEntries(Object.entries(namesIntoGroups))
+ .map(([_, names], index, collection) => (
+ <>
+ {names.map(({ key, name }) => {
+ let content;
+
+ if (currentlyEditingName === key) {
+ const updateName = (event, value) => {
+ props.handleUpdateName(
+ key,
+ value,
+ );
+
+ setCurrentlyEditingName(null);
+ };
+
+ content = (
+ {
+ setCurrentlyEditingName(null);
+ }}
+ value={props.names[key]}
+ />
+ );
+ } else {
+ content = (
+ {
+ setCurrentlyEditingName(key);
+ event.cancelBubble = true;
+ event.stopPropagation();
+ }}
+ >
+
+ {props.names[key]}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {content}
+
+
+ {!!name.can_randomize && (
+
+ {
+ props.handleRandomizeName(key);
+ }}
+ />
+
+ )}
+
+
+ );
+ })}
+
+ {(index !== collection.length - 1)
+ && }
+ >
+ ))}
+
+
+
+
+ );
+ }} />
+ );
+};
+
+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);
+ }} textAlign="center" 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 000000000000..2e90e200c0f8
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/base.tsx
@@ -0,0 +1,355 @@
+import { sortBy, sortStrings } from "common/collections";
+import { BooleanLike, classes } from "common/react";
+import { ComponentType, createComponentVNode, InfernoNode } from "inferno";
+import { VNodeFlags } from "inferno-vnode-flags";
+import { sendAct, useBackend, useLocalState } from "../../../../backend";
+import { Box, Button, Dropdown, NumberInput, Stack } from "../../../../components";
+import { createSetPreference, PreferencesMenuData } from "../../data";
+import { ServerPreferencesFetcher } from "../../ServerPreferencesFetcher";
+
+export const sortChoices = sortBy<[string, InfernoNode]>(([name]) => name);
+
+export type Feature<
+ TReceiving,
+ TSending = TReceiving,
+ TServerData = undefined,
+> = {
+ name: string;
+ component: FeatureValue<
+ TReceiving,
+ TSending,
+ TServerData
+ >;
+ category?: string;
+ description?: string;
+};
+
+/**
+ * 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<
+ TReceiving,
+ TSending = TReceiving,
+ TServerData = undefined,
+>
+ = ComponentType>;
+
+export type FeatureValueProps<
+ TReceiving,
+ TSending = TReceiving,
+ TServerData = undefined,
+> = {
+ 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 CheckboxInput = (
+ props: FeatureValueProps & {
+ disabled?: boolean,
+ }
+) => {
+ 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?: Record,
+): FeatureValue => {
+ return (props: FeatureValueProps) => {
+ return ( {
+ return {
+ displayText: label,
+ value: dataValue,
+ };
+ })}
+ {...dropdownProps}
+ />);
+ };
+};
+
+export type FeatureChoicedServerData = {
+ choices: string[];
+ display_names?: Record;
+ icons?: Record;
+ key_locked?: Record;
+};
+
+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,
+}) => {
+ const {
+ choices,
+ disabled,
+ displayNames,
+ onSetValue,
+ value,
+ } = props;
+
+ return ( {
+ return {
+ displayText: displayNames[choice],
+ value: choice,
+ };
+ })
+ }
+ />);
+};
+
+export const FeatureDropdownInput = (
+ props: FeatureValueProps & {
+ disabled?: boolean,
+}, context) => {
+ const serverData = props.serverData;
+ if (!serverData) {
+ return null;
+ }
+
+ const { data } = useBackend(context);
+ const client_ckey = data.ckey;
+
+ const displayNames = serverData.display_names
+ || Object.fromEntries(
+ serverData.choices.map(choice => [choice, capitalizeFirstLetter(choice)])
+ );
+
+ let choices = sortStrings(serverData.choices);
+ if (serverData.key_locked)
+ {
+ const key_locked = serverData.key_locked;
+
+ choices = choices.filter(choice => {
+ const choice_ckey = key_locked[choice];
+ if (choice_ckey)
+ {
+ return choice_ckey === client_ckey;
+ }
+
+ return true;
+ });
+ }
+
+ return ( );
+};
+
+export type FeatureWithIcons = Feature<
+ { value: T },
+ T,
+ FeatureChoicedServerData
+>;
+
+export const FeatureIconnedDropdownInput = (
+ props: FeatureValueProps<{
+ value: string,
+ }, string, FeatureChoicedServerData>,
+) => {
+ 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 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] = useLocalState(
+ context,
+ `${props.featureId}_predictedValue_${data.active_slot}`,
+ 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/accent.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/accent.tsx
new file mode 100644
index 000000000000..8b4b853e15a2
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/accent.tsx
@@ -0,0 +1,6 @@
+import { FeatureChoiced, FeatureDropdownInput } from "../base";
+
+export const accent: FeatureChoiced = {
+ name: "Accent",
+ component: FeatureDropdownInput,
+};
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 000000000000..41889a1fc685
--- /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 000000000000..ea3ab4de08c6
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/ai_core_display.tsx
@@ -0,0 +1,6 @@
+import { FeatureDropdownInput, FeatureChoiced } from "../base";
+
+export const preferred_ai_core_display: FeatureChoiced = {
+ name: "AI core display",
+ component: FeatureDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/bar_choice.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/bar_choice.tsx
new file mode 100644
index 000000000000..6b12731e069a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/bar_choice.tsx
@@ -0,0 +1,6 @@
+import { FeatureChoiced, FeatureDropdownInput } from "../base";
+
+export const bar_choice: FeatureChoiced = {
+ name: "Bar choice",
+ component: FeatureDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/engineering_department.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/engineering_department.tsx
new file mode 100644
index 000000000000..ce7d999614c0
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/engineering_department.tsx
@@ -0,0 +1,6 @@
+import { FeatureChoiced, FeatureDropdownInput } from "../base";
+
+export const prefered_engineering_department: FeatureChoiced = {
+ name: "Engineering department",
+ component: FeatureDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/persistent_scars.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/persistent_scars.tsx
new file mode 100644
index 000000000000..8ca5a7be6465
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/persistent_scars.tsx
@@ -0,0 +1,6 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const persistent_scars: FeatureToggle = {
+ name: "Persistent Scars",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/phobia.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/phobia.tsx
new file mode 100644
index 000000000000..04d91daea06d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/phobia.tsx
@@ -0,0 +1,6 @@
+import { FeatureChoiced, FeatureDropdownInput } from "../base";
+
+export const phobia: FeatureChoiced = {
+ name: "Phobia",
+ component: FeatureDropdownInput,
+};
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 000000000000..118f2cf46785
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/security_department.tsx
@@ -0,0 +1,6 @@
+import { FeatureChoiced, FeatureDropdownInput } from "../base";
+
+export const prefered_security_department: FeatureChoiced = {
+ name: "Security department",
+ component: FeatureDropdownInput,
+};
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 000000000000..3ee93eb571bd
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/skin_tone.tsx
@@ -0,0 +1,65 @@
+import { sortBy } from "common/collections";
+import { Box, Stack } from "../../../../../components";
+import { Feature, FeatureChoicedServerData, FeatureValueProps, StandardizedDropdown } from "../base";
+
+type HexValue = {
+ lightness: number,
+ value: string,
+};
+
+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)}
+ displayNames={Object.fromEntries(
+ Object.entries(serverData.display_names)
+ .map(([key, displayName]) => {
+ const hexColor = serverData.to_hex[key];
+
+ return [key, (
+
+
+
+
+
+
+ {displayName}
+
+
+ )];
+ })
+ )}
+ onSetValue={handleSetValue}
+ value={value}
+ />
+ );
+ },
+};
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 000000000000..459892597089
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/uplink_loc.tsx
@@ -0,0 +1,6 @@
+import { FeatureChoiced, FeatureDropdownInput } from "../base";
+
+export const uplink_loc: FeatureChoiced = {
+ name: "Uplink Spawn Location",
+ component: FeatureDropdownInput,
+};
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 000000000000..f679b6efdd31
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/admin.tsx
@@ -0,0 +1,15 @@
+import { CheckboxInput, FeatureColorInput, Feature, FeatureDropdownInput, FeatureToggle } from "../base";
+
+export const asaycolor: Feature = {
+ name: "Admin chat color",
+ category: "ADMIN",
+ description: "The color of your messages in Adminsay. Doesn't work, shout at Jamie.",
+ component: FeatureColorInput,
+};
+
+export const fast_mc_refresh: FeatureToggle = {
+ name: "Enable fast MC stat panel refreshes",
+ category: "ADMIN",
+ description: "Whether or not the MC tab of the Stat Panel refreshes fast. This is expensive so make sure you need it.",
+ 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 000000000000..4884c07377fb
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ambient_occlusion.tsx
@@ -0,0 +1,8 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const ambientocclusion: FeatureToggle = {
+ name: "Enable ambient occlusion",
+ category: "GAMEPLAY",
+ description: "Enable ambient occlusion, light shadows around characters.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/announcer.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/announcer.tsx
new file mode 100644
index 000000000000..b731218a71c3
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/announcer.tsx
@@ -0,0 +1,7 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const disable_alternative_announcers: FeatureToggle = {
+ name: "Disable alternative announcers",
+ category: "SOUND",
+ 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 000000000000..5339de43d15a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/auto_fit_viewport.tsx
@@ -0,0 +1,7 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const auto_fit_viewport: FeatureToggle = {
+ name: "Auto fit viewport",
+ category: "UI",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/balloon_alerts.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/balloon_alerts.tsx
new file mode 100644
index 000000000000..3dbf1bd12edc
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/balloon_alerts.tsx
@@ -0,0 +1,7 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const disable_balloon_alerts: FeatureToggle = {
+ name: "Disable balloon alerts",
+ category: "UI",
+ 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 000000000000..67849342094d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/buttons_locked.tsx
@@ -0,0 +1,8 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const buttons_locked: FeatureToggle = {
+ name: "Lock action buttons",
+ category: "GAMEPLAY",
+ description: "When enabled, action buttons will be locked in place.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/donor.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/donor.tsx
new file mode 100644
index 000000000000..ada854b5d48a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/donor.tsx
@@ -0,0 +1,102 @@
+import { CheckboxInput, Feature, FeatureChoicedServerData, FeatureToggle, FeatureDropdownInput, FeatureValueProps } from "../base";
+import { BooleanLike } from "common/react";
+import { useBackend } from "../../../../../backend";
+import { PreferencesMenuData } from "../../../data";
+
+export const donor_hat: Feature = {
+ name: "Donor hat",
+ category: "DONATOR",
+ component: (
+ props: FeatureValueProps,
+ context,
+ ) => {
+ const { data } = useBackend(context);
+
+ return ( );
+ },
+};
+
+export const donor_item: Feature = {
+ name: "Donor item",
+ category: "DONATOR",
+ component: (
+ props: FeatureValueProps,
+ context,
+ ) => {
+ const { data } = useBackend(context);
+
+ return ( );
+ },
+};
+
+export const donor_plush: Feature = {
+ name: "Donor plush",
+ category: "DONATOR",
+ component: (
+ props: FeatureValueProps,
+ context,
+ ) => {
+ const { data } = useBackend(context);
+
+ return ( );
+ },
+};
+
+export const borg_hat: FeatureToggle = {
+ name: "Equip borg hat",
+ category: "DONATOR",
+ description: "When enabled, you will equip your selected donor hat when playing cyborg.",
+ component: (
+ props: FeatureValueProps,
+ context,
+ ) => {
+ const { data } = useBackend(context);
+
+ return ( );
+ },
+};
+
+export const donor_pda: Feature = {
+ name: "Donor PDA",
+ category: "DONATOR",
+ component: (
+ props: FeatureValueProps,
+ context,
+ ) => {
+ const { data } = useBackend(context);
+
+ return ( );
+ },
+};
+
+export const purrbation: FeatureToggle = {
+ name: "Purrbation",
+ category: "DONATOR",
+ description: "When enabled and you are a human, you will turn into a felinid.",
+ component: (
+ props: FeatureValueProps,
+ context,
+ ) => {
+ const { data } = useBackend(context);
+
+ return ( );
+ },
+};
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 000000000000..55c091a9714f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/fps.tsx
@@ -0,0 +1,56 @@
+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 || 60);
+ }
+ }}
+ width="100%"
+ 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: "GAMEPLAY",
+ component: FpsInput,
+};
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 000000000000..5e985b44e642
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ghost.tsx
@@ -0,0 +1,139 @@
+import { multiline } from "common/string";
+import { CheckboxInput, FeatureChoiced, FeatureChoicedServerData, FeatureDropdownInput, FeatureToggle, FeatureValueProps } from "../base";
+import { Box, Dropdown, Flex } from "../../../../../components";
+import { classes } from "common/react";
+import { 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",
+ description: "Determines what adjustments your ghost will have.",
+ component: FeatureDropdownInput,
+};
+
+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 form",
+ category: "GHOST",
+ description: "The appearance of your ghost. Requires donator to customize.",
+ component: GhostFormInput,
+};
+
+export const ghost_hud: FeatureToggle = {
+ name: "Ghost HUD",
+ category: "GHOST",
+ description: "Enable HUD buttons for ghosts.",
+ component: CheckboxInput,
+};
+
+export const ghost_orbit: FeatureChoiced = {
+ name: "Ghost orbit",
+ category: "GHOST",
+ description: multiline`
+ The shape in which your ghost will orbit.
+ Requires donator to customize.
+ `,
+ component: (
+ props: FeatureValueProps,
+ context,
+ ) => {
+ const { data } = useBackend(context);
+
+ return ( );
+ },
+};
+
+export const ghost_others: FeatureChoiced = {
+ name: "Ghosts of others",
+ category: "GHOST",
+ 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: FeatureDropdownInput,
+};
+
+export const inquisitive_ghost: FeatureToggle = {
+ name: "Ghost inquisitiveness",
+ category: "GHOST",
+ description: "When enabled, clicking on something as a ghost will examine it.",
+ 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 000000000000..73cc3c9de16e
--- /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 000000000000..3877da11007b
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/item_outlines.tsx
@@ -0,0 +1,8 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const itemoutline_pref: FeatureToggle = {
+ name: "Item outlines",
+ category: "GAMEPLAY",
+ description: "When enabled, hovering over items will outline them.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/items.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/items.tsx
new file mode 100644
index 000000000000..a0bb31e95f1a
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/items.tsx
@@ -0,0 +1,15 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const flare: FeatureToggle = {
+ name: "Spawn with flare",
+ category: "GAMEPLAY",
+ description: "If enabled, you will spawn with a flare in your backpack.",
+ component: CheckboxInput,
+};
+
+export const map: FeatureToggle = {
+ name: "Spawn with map",
+ category: "GAMEPLAY",
+ description: "If enabled, you will spawn with a map in your backpack.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_chat_toggles.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_chat_toggles.tsx
new file mode 100644
index 000000000000..dc359e57d459
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_chat_toggles.tsx
@@ -0,0 +1,89 @@
+import { multiline } from "common/string";
+import { FeatureToggle, CheckboxInput } from "../base";
+
+export const chat_bankcard: FeatureToggle = {
+ name: "Enable income updates",
+ category: "CHAT",
+ description: "Receive notifications for your bank account.",
+ component: CheckboxInput,
+};
+
+export const chat_dead: FeatureToggle = {
+ name: "Enable deadchat",
+ category: "ADMIN",
+ component: CheckboxInput,
+};
+
+export const chat_ghostears: FeatureToggle = {
+ name: "Hear all messages",
+ category: "GHOST",
+ 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: "Enable law change updates",
+ category: "GHOST",
+ description: "When enabled, be notified of any new law changes as a ghost.",
+ component: CheckboxInput,
+};
+
+export const chat_ghostpda: FeatureToggle = {
+ name: "Enable PDA notifications",
+ category: "GHOST",
+ description: "When enabled, be notified of any PDA messages as a ghost.",
+ component: CheckboxInput,
+};
+
+export const chat_ghostradio: FeatureToggle = {
+ name: "Enable radio",
+ category: "GHOST",
+ description: "When enabled, be notified of any radio messages as a ghost.",
+ component: CheckboxInput,
+};
+
+export const chat_ghostsight: FeatureToggle = {
+ name: "See all emotes",
+ category: "GHOST",
+ description: "When enabled, see all emotes as a ghost.",
+ component: CheckboxInput,
+};
+
+export const chat_ghostwhisper: FeatureToggle = {
+ name: "See all whispers",
+ category: "GHOST",
+ 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_login_logout: FeatureToggle = {
+ name: "See login/logout messages",
+ category: "GHOST",
+ description: "When enabled, be notified when a player logs in or out.",
+ component: CheckboxInput,
+};
+
+export const chat_ooc: FeatureToggle = {
+ name: "Enable OOC",
+ category: "CHAT",
+ component: CheckboxInput,
+};
+
+export const chat_prayer: FeatureToggle = {
+ name: "Listen to prayers",
+ category: "ADMIN",
+ component: CheckboxInput,
+};
+
+export const chat_pullr: FeatureToggle = {
+ name: "Enable pull request notifications",
+ category: "CHAT",
+ description: "Be notified when a pull request is made, closed, or merged.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_toggles.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_toggles.tsx
new file mode 100644
index 000000000000..6b6c44661e5c
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_toggles.tsx
@@ -0,0 +1,153 @@
+import { multiline } from "common/string";
+import { FeatureToggle, CheckboxInput, CheckboxInputInverse } from "../base";
+
+export const admin_ignore_cult_ghost: FeatureToggle = {
+ name: "Prevent being summoned as a cult ghost",
+ category: "ADMIN",
+ description: multiline`
+ When enabled and observing, prevents Spirit Realm from forcing you
+ into a cult ghost.
+ `,
+ component: CheckboxInput,
+};
+
+export const announce_login: FeatureToggle = {
+ name: "Announce login",
+ category: "ADMIN",
+ description: "Admins will be notified when you login.",
+ component: CheckboxInput,
+};
+
+export const combohud_lighting: FeatureToggle = {
+ name: "Enable fullbright Combo HUD",
+ category: "ADMIN",
+ component: CheckboxInput,
+};
+
+export const deadmin_always: FeatureToggle = {
+ name: "Auto deadmin - Always",
+ category: "ADMIN",
+ description: "When enabled, you will automatically deadmin.",
+ component: CheckboxInput,
+};
+
+export const deadmin_antagonist: FeatureToggle = {
+ name: "Auto deadmin - Antagonist",
+ category: "ADMIN",
+ description: "When enabled, you will automatically deadmin as an antagonist.",
+ component: CheckboxInput,
+};
+
+export const deadmin_position_head: FeatureToggle = {
+ name: "Auto deadmin - Head of Staff",
+ category: "ADMIN",
+ description: "When enabled, you will automatically deadmin as a head of staff.",
+ component: CheckboxInput,
+};
+
+export const deadmin_position_security: FeatureToggle = {
+ name: "Auto deadmin - Security",
+ category: "ADMIN",
+ description: "When enabled, you will automatically deadmin as a member of security.",
+ component: CheckboxInput,
+};
+
+export const deadmin_position_silicon: FeatureToggle = {
+ name: "Auto deadmin - Silicon",
+ category: "ADMIN",
+ description: "When enabled, you will automatically deadmin as a silicon.",
+ component: CheckboxInput,
+};
+
+export const disable_arrivalrattle: FeatureToggle = {
+ name: "Notify for new arrivals",
+ category: "GHOST",
+ description: "When enabled, you will be notified as a ghost for new crew.",
+ component: CheckboxInputInverse,
+};
+
+export const disable_deathrattle: FeatureToggle = {
+ name: "Notify for deaths",
+ category: "GHOST",
+ description: "When enabled, you will be notified as a ghost whenever someone dies.",
+ component: CheckboxInputInverse,
+};
+
+export const member_public: FeatureToggle = {
+ name: "Publicize BYOND membership",
+ category: "CHAT",
+ description: "When enabled, a BYOND logo will be shown next to your name in OOC.",
+ component: CheckboxInput,
+};
+
+export const sound_adminhelp: FeatureToggle = {
+ name: "Enable adminhelp sounds",
+ category: "ADMIN",
+ component: CheckboxInput,
+};
+
+export const sound_ambience: FeatureToggle = {
+ name: "Enable ambience",
+ category: "SOUND",
+ component: CheckboxInput,
+};
+
+export const sound_announcements: FeatureToggle = {
+ name: "Enable announcement sounds",
+ category: "SOUND",
+ description: "When enabled, hear sounds for command reports, notices, etc.",
+ component: CheckboxInput,
+};
+
+export const sound_combatmode: FeatureToggle = {
+ name: "Enable combat mode sound",
+ category: "SOUND",
+ description: "When enabled, hear sounds when toggling combat mode.",
+ component: CheckboxInput,
+};
+
+export const sound_endofround: FeatureToggle = {
+ name: "Enable end of round sounds",
+ category: "SOUND",
+ description: "When enabled, hear a sound when the server is rebooting.",
+ component: CheckboxInput,
+};
+
+export const sound_instruments: FeatureToggle = {
+ name: "Enable instruments",
+ category: "SOUND",
+ description: "When enabled, be able hear instruments in game.",
+ component: CheckboxInput,
+};
+
+export const sound_lobby: FeatureToggle = {
+ name: "Enable lobby music",
+ category: "SOUND",
+ component: CheckboxInput,
+};
+
+export const sound_midi: FeatureToggle = {
+ name: "Enable admin music",
+ category: "SOUND",
+ description: "When enabled, admins will be able to play music to you.",
+ component: CheckboxInput,
+};
+
+export const sound_prayers: FeatureToggle = {
+ name: "Enable prayer sound",
+ category: "ADMIN",
+ component: CheckboxInput,
+};
+
+export const sound_ship_ambience: FeatureToggle = {
+ name: "Enable ship ambience",
+ category: "SOUND",
+ component: CheckboxInput,
+};
+
+export const split_admin_tabs: FeatureToggle = {
+ name: "Split admin tabs",
+ category: "ADMIN",
+ description: "When enabled, will split the 'Admin' panel into several tabs.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_yog_toggles.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_yog_toggles.tsx
new file mode 100644
index 000000000000..28b2340b3dde
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/legacy_yog_toggles.tsx
@@ -0,0 +1,15 @@
+import { FeatureToggle, CheckboxInput } from "../base";
+
+export const quiet_mode: FeatureToggle = {
+ name: "Quiet mode",
+ category: "DONATOR",
+ description: "You cannot be chosen as an antagonist or antagonist target.",
+ component: CheckboxInput,
+};
+
+export const pref_mood: FeatureToggle = {
+ name: "Enable mood",
+ category: "GAMEPLAY",
+ description: "Enable the mood system.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/mood.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/mood.tsx
new file mode 100644
index 000000000000..73fbf0fbe449
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/mood.tsx
@@ -0,0 +1,8 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const mood_tail_wagging: FeatureToggle = {
+ name: "Mood tail wagging",
+ category: "GAMEPLAY",
+ description: "If you enabled mood, your tail will wag based on your mood.",
+ component: CheckboxInput,
+};
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 000000000000..e4f5c5466f0e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ooc.tsx
@@ -0,0 +1,8 @@
+import { FeatureColorInput, Feature } from "../base";
+
+export const ooccolor: Feature = {
+ name: "OOC color",
+ category: "CHAT",
+ description: "The color of your OOC messages.",
+ component: FeatureColorInput,
+};
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 000000000000..1fbb2230ff20
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/parallax.tsx
@@ -0,0 +1,7 @@
+import { Feature, FeatureDropdownInput } from "../base";
+
+export const parallax: Feature = {
+ name: "Parallax (fancy space)",
+ category: "GAMEPLAY",
+ component: FeatureDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pda.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pda.tsx
new file mode 100644
index 000000000000..fee68b9f8201
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pda.tsx
@@ -0,0 +1,28 @@
+import { CheckboxInput, Feature, FeatureColorInput, FeatureDropdownInput, FeatureToggle } from "../base";
+
+export const pda_color: Feature = {
+ name: "PDA color",
+ category: "GAMEPLAY",
+ description: "The background color of your PDA.",
+ component: FeatureColorInput,
+};
+
+export const pda_style: Feature = {
+ name: "PDA style",
+ category: "GAMEPLAY",
+ description: "The style of your equipped PDA. Changes font.",
+ component: FeatureDropdownInput,
+};
+
+export const pda_theme: Feature = {
+ name: "PDA theme",
+ category: "GAMEPLAY",
+ description: "The theme of your equipped PDA.",
+ component: FeatureDropdownInput,
+};
+
+export const id_in_pda: FeatureToggle = {
+ name: "Spawn with ID in PDA",
+ category: "GAMEPLAY",
+ component: CheckboxInput,
+};
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 000000000000..bb07453e340f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/pixel_size.tsx
@@ -0,0 +1,13 @@
+import { createDropdownInput, Feature } from "../base";
+
+export const pixel_size: Feature = {
+ name: "Pixel Scaling",
+ category: "UI",
+ component: createDropdownInput({
+ 0: "Stretch to fit",
+ 1: "Pixel Perfect 1x",
+ 1.5: "Pixel Perfect 1.5x",
+ 2: "Pixel Perfect 2x",
+ 3: "Pixel Perfect 3x",
+ }),
+};
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 000000000000..3acfad00445a
--- /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 { Feature, FeatureDropdownInput } from "../base";
+
+export const preferred_map: Feature = {
+ 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: FeatureDropdownInput,
+};
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 000000000000..d85a814d8add
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/runechat.tsx
@@ -0,0 +1,29 @@
+import { CheckboxInput, FeatureNumberInput, FeatureNumeric, FeatureToggle } from "../base";
+
+export const chat_on_map: FeatureToggle = {
+ name: "Enable Runechat",
+ category: "RUNECHAT",
+ description: "Chat messages will show above heads.",
+ component: CheckboxInput,
+};
+
+export const see_chat_non_mob: FeatureToggle = {
+ name: "Enable Runechat on objects",
+ category: "RUNECHAT",
+ description: "Chat messages will show above objects when they speak.",
+ component: CheckboxInput,
+};
+
+export const see_rc_emotes: FeatureToggle = {
+ name: "Enable Runechat emotes",
+ category: "RUNECHAT",
+ description: "Emotes will show above heads.",
+ component: CheckboxInput,
+};
+
+export const max_chat_length: FeatureNumeric = {
+ name: "Max chat length",
+ category: "RUNECHAT",
+ description: "The maximum length a Runechat message will show as.",
+ component: FeatureNumberInput,
+};
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 000000000000..114cc80ef5e8
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/scaling_method.tsx
@@ -0,0 +1,11 @@
+import { createDropdownInput, Feature } from "../base";
+
+export const scaling_method: Feature = {
+ name: "Scaling method",
+ category: "UI",
+ component: createDropdownInput({
+ blur: "Bilinear",
+ distort: "Nearest Neighbor",
+ normal: "Point Sampling",
+ }),
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/screentips.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/screentips.tsx
new file mode 100644
index 000000000000..b397388aa3da
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/screentips.tsx
@@ -0,0 +1,22 @@
+import { multiline } from "common/string";
+import { FeatureColorInput, Feature, FeatureChoiced, FeatureDropdownInput } from "../base";
+
+export const screentip_color: Feature = {
+ name: "Screentips color",
+ category: "UI",
+ description: multiline`
+ The color of screen tips, the text you see when hovering over something.
+ `,
+ component: FeatureColorInput,
+};
+
+export const screentip_pref: FeatureChoiced = {
+ name: "Enable screentips",
+ category: "UI",
+ description: multiline`
+ Enables screen tips, the text you see when hovering over something.
+ When set to "Only with tips", will only show when there is more information
+ than just the name, such as what right-clicking it does.
+ `,
+ component: FeatureDropdownInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/show_credits.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/show_credits.tsx
new file mode 100644
index 000000000000..518bd86fa79e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/show_credits.tsx
@@ -0,0 +1,8 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const show_credits: FeatureToggle = {
+ name: "Show credits",
+ category: "GAMEPLAY",
+ description: "Display the credits at the end of the round.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/skillcape.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/skillcape.tsx
new file mode 100644
index 000000000000..bab3df7207a6
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/skillcape.tsx
@@ -0,0 +1,35 @@
+import { useBackend } from "../../../../../backend";
+import { PreferencesMenuData } from "../../../data";
+import { FeatureChoiced, FeatureChoicedServerData, FeatureValueProps, StandardizedDropdown } from "../base";
+
+const SkillcapeDropdownInput = (
+ props: FeatureValueProps & {
+ disabled?: boolean,
+}, context) => {
+ const serverData = props.serverData;
+ if (!serverData) {
+ return null;
+ }
+
+ const { data } = useBackend(context);
+
+ const displayNames = serverData.display_names
+ || Object.fromEntries(
+ serverData.choices.map(choice => [choice, choice]) // Shouldn't be here, skillcapes provide display names
+ );
+
+ return ( );
+};
+
+export const skillcape_id: FeatureChoiced = {
+ name: "Skillcape",
+ category: "GAMEPLAY",
+ description: "Prove your expertise of a job by wearing a special skillcape, only unlocked after 500 hours of playtime.",
+ component: SkillcapeDropdownInput,
+};
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 000000000000..ee9a699c019f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx
@@ -0,0 +1,15 @@
+import { CheckboxInput, FeatureToggle } from '../base';
+
+export const tgui_fancy: FeatureToggle = {
+ name: 'Enable fancy TGUI',
+ category: 'UI',
+ 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',
+ description: 'Locks TGUI windows to your main monitor.',
+ 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 000000000000..d3147a01a1f2
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tooltips.tsx
@@ -0,0 +1,20 @@
+import { multiline } from "common/string";
+import { CheckboxInput, Feature, FeatureNumberInput, FeatureToggle } from "../base";
+
+export const enable_tips: FeatureToggle = {
+ name: "Enable tooltips",
+ category: "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: "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 000000000000..83a315bccac1
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/ui_style.tsx
@@ -0,0 +1,63 @@
+import { classes } from "common/react";
+import { FeatureChoiced, FeatureChoicedServerData, FeatureValueProps, sortChoices } 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: "UI Style",
+ category: "UI",
+ component: UIStyleInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/widescreen.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/widescreen.tsx
new file mode 100644
index 000000000000..e4d7595775ee
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/widescreen.tsx
@@ -0,0 +1,7 @@
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const widescreenpref: FeatureToggle = {
+ name: "Enable widescreen",
+ category: "UI",
+ 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 000000000000..58f32c947688
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/window_flashing.tsx
@@ -0,0 +1,12 @@
+import { multiline } from "common/string";
+import { CheckboxInput, FeatureToggle } from "../base";
+
+export const windowflashing: FeatureToggle = {
+ name: "Enable window flashing",
+ category: "UI",
+ 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 000000000000..d487a282e58c
--- /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 000000000000..e115b9758b2e
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/randomization.tsx
@@ -0,0 +1,85 @@
+import { useBackend } from "../../../../backend";
+import { Button, Stack } from "../../../../components";
+import { PreferencesMenuData, RandomSetting } from "../../data";
+import { RandomizationButton } from "../../RandomizationButton";
+import { useRandomToggleState } from "../../useRandomToggleState";
+import { CheckboxInput, Feature, FeatureToggle } from "./base";
+
+export const random_body: 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 random_name: 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/features/species_features.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/species_features.tsx
new file mode 100644
index 000000000000..d4ba7f5a1768
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/species_features.tsx
@@ -0,0 +1,101 @@
+import { FeatureColorInput, Feature, FeatureChoiced, FeatureDropdownInput } from "./base";
+
+export const eye_color: Feature = {
+ name: "Eye color",
+ component: FeatureColorInput,
+};
+
+export const facial_hair_color: Feature = {
+ name: "Facial hair color",
+ component: FeatureColorInput,
+};
+
+export const hair_color: Feature = {
+ name: "Hair color",
+ component: FeatureColorInput,
+};
+
+export const feature_gradientstyle: FeatureChoiced = {
+ name: "Hair gradient",
+ component: FeatureDropdownInput,
+};
+
+export const feature_gradientcolor: Feature = {
+ name: "Hair gradient color",
+ component: FeatureColorInput,
+};
+
+export const feature_lizard_legs: FeatureChoiced = {
+ name: "Legs",
+ component: FeatureDropdownInput,
+};
+
+export const feature_lizard_snout: FeatureChoiced = {
+ name: "Snout",
+ component: FeatureDropdownInput,
+};
+
+export const feature_lizard_spines: FeatureChoiced = {
+ name: "Spines",
+ component: FeatureDropdownInput,
+};
+
+export const feature_lizard_tail: FeatureChoiced = {
+ name: "Tail",
+ component: FeatureDropdownInput,
+};
+
+export const feature_mcolor: Feature = {
+ name: "Mutant color",
+ component: FeatureColorInput,
+};
+
+export const feature_ipc_screen: FeatureChoiced = {
+ name: "Screen",
+ component: FeatureDropdownInput,
+};
+
+export const feature_ipc_antenna: FeatureChoiced = {
+ name: "Antenna",
+ component: FeatureDropdownInput,
+};
+
+export const feature_ipc_chassis: FeatureChoiced = {
+ name: "Chassis",
+ component: FeatureDropdownInput,
+};
+
+export const feature_polysmorph_tail: FeatureChoiced = {
+ name: "Tail",
+ component: FeatureDropdownInput,
+};
+
+export const feature_polysmorph_teeth: FeatureChoiced = {
+ name: "Teeth",
+ component: FeatureDropdownInput,
+};
+
+export const feature_polysmorph_dome: FeatureChoiced = {
+ name: "Dome",
+ component: FeatureDropdownInput,
+};
+
+export const feature_polysmorph_dorsal_tubes: FeatureChoiced = {
+ name: "Dorsal tubes",
+ component: FeatureDropdownInput,
+};
+
+export const feature_pod_hair: FeatureChoiced = {
+ name: "Pod hair style",
+ component: FeatureDropdownInput,
+};
+
+export const feature_plasmaman_helmet: FeatureChoiced = {
+ name: "Helmet style",
+ component: FeatureDropdownInput,
+};
+
+export const feature_ethereal_mark: FeatureChoiced = {
+ name: "Mark",
+ component: FeatureDropdownInput,
+};
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 000000000000..787a3c900cb0
--- /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 000000000000..6721ee70cf5c
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/useRandomToggleState.ts
@@ -0,0 +1,4 @@
+import { useLocalState } from "../../backend";
+
+export const useRandomToggleState
+ = context => useLocalState(context, "randomToggle", false);
diff --git a/tgui/packages/tgui/layouts/Window.js b/tgui/packages/tgui/layouts/Window.js
index ecde6bd44321..64c45f987906 100644
--- a/tgui/packages/tgui/layouts/Window.js
+++ b/tgui/packages/tgui/layouts/Window.js
@@ -28,7 +28,7 @@ export class Window extends Component {
if (suspended) {
return;
}
- Byond.winset(window.__windowId__, {
+ Byond.winset(Byond.windowId, {
'can-close': Boolean(canClose),
});
logger.log('mounting');
@@ -60,13 +60,13 @@ export class Window extends Component {
recallWindowGeometry(options);
}
-
render() {
const {
canClose = true,
theme,
title,
children,
+ buttons,
} = this.props;
const {
config,
@@ -95,7 +95,9 @@ export class Window extends Component {
logger.log('pressed close');
dispatch(backendSuspendStart());
}}
- canClose={canClose} />
+ canClose={canClose}>
+ {buttons}
+
{
fancy,
onDragStart,
onClose,
+ children,
} = props;
const dispatch = useDispatch(context);
return (
@@ -186,15 +189,20 @@ const TitleBar = (props, context) => {
color={statusToColor(status)}
name="eye" />
)}
+
fancy && onDragStart(e)} />
{typeof title === 'string'
&& title === title.toLowerCase()
&& toTitleCase(title)
|| title}
+ {!!children && (
+
+ {children}
+
+ )}
-
fancy && onDragStart(e)} />
{process.env.NODE_ENV !== 'production' && (
{
// Subscribe to all document clicks
document.addEventListener('click', e => {
- const tagName = String(e.target.tagName).toLowerCase();
- const hrefAttr = e.target.getAttribute('href') || '';
- // Must be a link
- if (tagName !== 'a') {
- return;
+ /** @type {HTMLElement} */
+ let target = e.target;
+ // Recurse down the tree to find a valid link
+ while (true) {
+ // Reached the end, bail.
+ if (!target || target === document.body) {
+ return;
+ }
+ const tagName = String(target.tagName).toLowerCase();
+ if (tagName === 'a') {
+ break;
+ }
+ target = target.parentElement;
}
+ const hrefAttr = target.getAttribute('href') || '';
// Leave BYOND links alone
const isByondLink = hrefAttr.charAt(0) === '?'
|| hrefAttr.startsWith('byond://');
@@ -30,9 +39,7 @@ export const captureExternalLinks = () => {
url = 'https://' + url;
}
// Open the link
- Byond.topic({
- tgui: 1,
- window_id: window.__windowId__,
+ Byond.sendMessage({
type: 'openLink',
url,
});
diff --git a/tgui/packages/tgui/logging.js b/tgui/packages/tgui/logging.js
index 663d524bdc61..d44b67ebc5b8 100644
--- a/tgui/packages/tgui/logging.js
+++ b/tgui/packages/tgui/logging.js
@@ -32,9 +32,7 @@ const log = (level, ns, ...args) => {
.filter(value => value)
.join(' ')
+ '\nUser Agent: ' + navigator.userAgent;
- Byond.topic({
- tgui: 1,
- window_id: window.__windowId__,
+ Byond.sendMessage({
type: 'log',
ns,
message: logEntry,
diff --git a/tgui/packages/tgui/package.json b/tgui/packages/tgui/package.json
index a0572e71ca1e..5687a76c66ab 100644
--- a/tgui/packages/tgui/package.json
+++ b/tgui/packages/tgui/package.json
@@ -5,10 +5,12 @@
"dependencies": {
"@popperjs/core": "^2.9.3",
"common": "workspace:*",
+ "dateformat": "^4.5.1",
"dompurify": "^2.3.1",
"inferno": "^7.4.8",
"inferno-vnode-flags": "^7.4.8",
- "marked": "^4.0.10",
+ "js-yaml": "^4.1.0",
+ "marked": "^2.1.3",
"tgui-dev-server": "workspace:*",
"tgui-polyfill": "workspace:*"
}
diff --git a/tgui/packages/tgui/public/tgui-common.chunk.js b/tgui/packages/tgui/public/tgui-common.chunk.js
deleted file mode 100644
index 78754e19c351..000000000000
--- a/tgui/packages/tgui/public/tgui-common.chunk.js
+++ /dev/null
@@ -1 +0,0 @@
-(window.webpackJsonp=window.webpackJsonp||[]).push([[0],[function(e,t,n){"use strict";t.__esModule=!0;var r=n(447);Object.keys(r).forEach((function(e){"default"!==e&&"__esModule"!==e&&(t[e]=r[e])}))},function(e,t,n){"use strict";t.__esModule=!0,t.TimeDisplay=t.Tooltip=t.Tabs=t.TextArea=t.Table=t.Slider=t.Section=t.ProgressBar=t.NumberInput=t.NoticeBox=t.Modal=t.LabeledList=t.LabeledControls=t.Knob=t.Input=t.Icon=t.Grid=t.Flex=t.Dropdown=t.DraggableControl=t.Divider=t.Dimmer=t.ColorBox=t.Collapsible=t.Chart=t.ByondUi=t.Button=t.Box=t.BlockQuote=t.AnimatedNumber=void 0;var r=n(139);t.AnimatedNumber=r.AnimatedNumber;var o=n(463);t.BlockQuote=o.BlockQuote;var i=n(18);t.Box=i.Box;var a=n(404);t.Button=a.Button;var c=n(465);t.ByondUi=c.ByondUi;var u=n(467);t.Chart=u.Chart;var s=n(468);t.Collapsible=s.Collapsible;var l=n(469);t.ColorBox=l.ColorBox;var f=n(406);t.Dimmer=f.Dimmer;var d=n(407);t.Divider=d.Divider;var p=n(140);t.DraggableControl=p.DraggableControl;var h=n(470);t.Dropdown=h.Dropdown;var g=n(141);t.Flex=g.Flex;var v=n(471);t.Grid=v.Grid;var m=n(105);t.Icon=m.Icon;var y=n(408);t.Input=y.Input;var b=n(472);t.Knob=b.Knob;var w=n(473);t.LabeledControls=w.LabeledControls;var x=n(409);t.LabeledList=x.LabeledList;var _=n(474);t.Modal=_.Modal;var E=n(475);t.NoticeBox=E.NoticeBox;var k=n(143);t.NumberInput=k.NumberInput;var S=n(476);t.ProgressBar=S.ProgressBar;var C=n(477);t.Section=C.Section;var N=n(478);t.Slider=N.Slider;var A=n(142);t.Table=A.Table;var T=n(479);t.TextArea=T.TextArea;var O=n(480);t.Tabs=O.Tabs;var I=n(405);t.Tooltip=I.Tooltip;var M=n(481);t.TimeDisplay=M.TimeDisplay},function(e,t,n){"use strict";(function(e){t.__esModule=!0,t.useSharedState=t.useLocalState=t.useBackend=t.selectBackend=t.sendAct=t.sendMessage=t.backendMiddleware=t.backendReducer=t.backendSuspendSuccess=t.backendSuspendStart=t.backendSetSharedState=t.backendUpdate=void 0;var r=n(101),o=n(401),i=n(402),a=n(24),c=n(137);var u=(0,a.createLogger)("backend"),s=function(e){return{type:"backend/update",payload:e}};t.backendUpdate=s;var l=function(e,t){return{type:"backend/setSharedState",payload:{key:e,nextState:t}}};t.backendSetSharedState=l;t.backendSuspendStart=function(){return{type:"backend/suspendStart"}};var f=function(){return{type:"backend/suspendSuccess",payload:{timestamp:Date.now()}}};t.backendSuspendSuccess=f;var d={config:{},data:{},shared:{},suspended:Date.now(),suspending:!1};t.backendReducer=function(e,t){void 0===e&&(e=d);var n=t.type,r=t.payload;if("backend/update"===n){var o=Object.assign({},e.config,r.config),i=Object.assign({},e.data,r.static_data,r.data),a=Object.assign({},e.shared);if(r.shared)for(var c=0,u=Object.keys(r.shared);c
=0||(o[n]=e[n]);return o}(t,["payload"]),o=Object.assign({tgui:1,window_id:window.__windowId__},r);null!==n&&n!==undefined&&(o.payload=JSON.stringify(n)),Byond.topic(o)};t.sendMessage=p;var h=function(e,t){void 0===t&&(t={}),"object"!=typeof t||null===t||Array.isArray(t)?u.error("Payload for act() must be an object, got this:",t):p({type:"act/"+e,payload:t})};t.sendAct=h;var g=function(e){return e.backend||{}};t.selectBackend=g;t.useBackend=function(e){var t=e.store,n=g(t.getState());return Object.assign({},n,{act:h})};t.useLocalState=function(e,t,n){var r,o=e.store,i=null!=(r=g(o.getState()).shared)?r:{},a=t in i?i[t]:n;return[a,function(e){o.dispatch(l(t,"function"==typeof e?e(a):e))}]};t.useSharedState=function(e,t,n){var r,o=e.store,i=null!=(r=g(o.getState()).shared)?r:{},a=t in i?i[t]:n;return[a,function(e){p({type:"setSharedState",key:t,value:JSON.stringify("function"==typeof e?e(a):e)||""})}]}}).call(this,n(103).setImmediate)},function(e,t,n){"use strict";t.__esModule=!0,t.Window=t.Pane=t.NtosWindow=t.Layout=void 0;var r=n(144);t.Layout=r.Layout;var o=n(482);t.NtosWindow=o.NtosWindow;var i=n(483);t.Pane=i.Pane;var a=n(410);t.Window=a.Window},function(e,t,n){"use strict";var r=n(7),o=n(23).f,i=n(32),a=n(27),c=n(110),u=n(156),s=n(70);e.exports=function(e,t){var n,l,f,d,p,h=e.target,g=e.global,v=e.stat;if(n=g?r:v?r[h]||c(h,{}):(r[h]||{}).prototype)for(l in t){if(d=t[l],f=e.noTargetGet?(p=o(n,l))&&p.value:n[l],!s(g?l:h+(v?".":"#")+l,e.forced)&&f!==undefined){if(typeof d==typeof f)continue;u(d,f)}(e.sham||f&&f.sham)&&i(d,"sham",!0),a(n,l,d,e)}}},function(e,t,n){"use strict";e.exports=function(e){try{return!!e()}catch(t){return!0}}},function(e,t,n){"use strict";t.__esModule=!0,t.canRender=t.pureComponentHooks=t.shallowDiffers=t.normalizeChildren=t.classes=void 0;t.classes=function(e){for(var t="",n=0;nn?n:e};t.clamp01=function(e){return e<0?0:e>1?1:e};t.scale=function(e,t,n){return(e-t)/(n-t)};t.round=function(e,t){return!e||isNaN(e)?e:(t|=0,i=(e*=n=Math.pow(10,t))>0|-(e<0),o=Math.abs(e%1)>=.4999999999854481,r=Math.floor(e),o&&(e=r+(i>0)),(o?e:Math.round(e))/n);var n,r,o,i};t.toFixed=function(e,t){return void 0===t&&(t=0),Number(e).toFixed(Math.max(t,0))};var r=function(e,t){return t&&e>=t[0]&&e<=t[1]};t.inRange=r;t.keyOfMatchingRange=function(e,t){for(var n=0,o=Object.keys(t);nc)return 1}return 0};t.sortBy=function(){for(var e=arguments.length,t=new Array(e),n=0;n0?o(r(e),9007199254740991):0}},function(e,t,n){"use strict";var r,o=n(125),i=n(10),a=n(7),c=n(8),u=n(20),s=n(85),l=n(32),f=n(27),d=n(16).f,p=n(41),h=n(55),g=n(15),v=n(67),m=a.Int8Array,y=m&&m.prototype,b=a.Uint8ClampedArray,w=b&&b.prototype,x=m&&p(m),_=y&&p(y),E=Object.prototype,k=E.isPrototypeOf,S=g("toStringTag"),C=v("TYPED_ARRAY_TAG"),N=o&&!!h&&"Opera"!==s(a.opera),A=!1,T={Int8Array:1,Uint8Array:1,Uint8ClampedArray:1,Int16Array:2,Uint16Array:2,Int32Array:4,Uint32Array:4,Float32Array:4,Float64Array:8},O=function(e){var t=s(e);return"DataView"===t||u(T,t)},I=function(e){return c(e)&&u(T,s(e))};for(r in T)a[r]||(N=!1);if((!N||"function"!=typeof x||x===Function.prototype)&&(x=function(){throw TypeError("Incorrect invocation")},N))for(r in T)a[r]&&h(a[r],x);if((!N||!_||_===E)&&(_=x.prototype,N))for(r in T)a[r]&&h(a[r].prototype,_);if(N&&p(w)!==_&&h(w,_),i&&!u(_,S))for(r in A=!0,d(_,S,{get:function(){return c(this)?this[C]:undefined}}),T)a[r]&&l(a[r],C,r);e.exports={NATIVE_ARRAY_BUFFER_VIEWS:N,TYPED_ARRAY_TAG:A&&C,aTypedArray:function(e){if(I(e))return e;throw TypeError("Target is not a typed array")},aTypedArrayConstructor:function(e){if(h){if(k.call(x,e))return e}else for(var t in T)if(u(T,r)){var n=a[t];if(n&&(e===n||k.call(n,e)))return e}throw TypeError("Target is not a typed array constructor")},exportTypedArrayMethod:function(e,t,n){if(i){if(n)for(var r in T){var o=a[r];o&&u(o.prototype,e)&&delete o.prototype[e]}_[e]&&!n||f(_,e,n?t:N&&y[e]||t)}},exportTypedArrayStaticMethod:function(e,t,n){var r,o;if(i){if(h){if(n)for(r in T)(o=a[r])&&u(o,e)&&delete o[e];if(x[e]&&!n)return;try{return f(x,e,n?t:N&&m[e]||t)}catch(c){}}for(r in T)!(o=a[r])||o[e]&&!n||f(o,e,t)}},isView:O,isTypedArray:I,TypedArray:x,TypedArrayPrototype:_}},function(e,t,n){"use strict";var r=n(7),o=n(112),i=n(20),a=n(67),c=n(116),u=n(159),s=o("wks"),l=r.Symbol,f=u?l:l&&l.withoutSetter||a;e.exports=function(e){return i(s,e)||(c&&i(l,e)?s[e]=l[e]:s[e]=f("Symbol."+e)),s[e]}},function(e,t,n){"use strict";var r=n(10),o=n(153),i=n(11),a=n(39),c=Object.defineProperty;t.f=r?c:function(e,t,n){if(i(e),t=a(t,!0),i(n),o)try{return c(e,t,n)}catch(r){}if("get"in n||"set"in n)throw TypeError("Accessors not supported");return"value"in n&&(e[t]=n.value),e}},function(e,t,n){"use strict";function r(e,t){var n;if("undefined"==typeof Symbol||null==e[Symbol.iterator]){if(Array.isArray(e)||(n=function(e,t){if(!e)return;if("string"==typeof e)return o(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return o(e,t)}(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var r=0;return function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}return(n=e[Symbol.iterator]()).next.bind(n)}function o(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n",apos:"'"};return e.replace(/ /gi,"\n").replace(/<\/?[a-z0-9-_]+[^>]*>/gi,"").replace(/&(nbsp|amp|quot|lt|gt|apos);/g,(function(e,n){return t[n]})).replace(/?([0-9]+);/gi,(function(e,t){var n=parseInt(t,10);return String.fromCharCode(n)})).replace(/?([0-9a-f]+);/gi,(function(e,t){var n=parseInt(t,16);return String.fromCharCode(n)}))};t.buildQueryString=function(e){return Object.keys(e).map((function(t){return encodeURIComponent(t)+"="+encodeURIComponent(e[t])})).join("&")}},function(e,t,n){"use strict";t.__esModule=!0,t.Box=t.computeBoxClassName=t.computeBoxProps=t.halfUnit=t.unit=void 0;var r=n(6),o=n(0),i=n(464),a=n(42);var c=function(e){return"string"==typeof e?e.endsWith("px")&&!Byond.IS_LTE_IE8?parseFloat(e)/12+"rem":e:"number"==typeof e?Byond.IS_LTE_IE8?12*e+"px":e+"rem":void 0};t.unit=c;var u=function(e){return"string"==typeof e?c(e):"number"==typeof e?c(.5*e):void 0};t.halfUnit=u;var s=function(e){return"string"==typeof e&&a.CSS_COLORS.includes(e)},l=function(e){return function(t,n){"number"!=typeof n&&"string"!=typeof n||(t[e]=n)}},f=function(e,t){return function(n,r){"number"!=typeof r&&"string"!=typeof r||(n[e]=t(r))}},d=function(e,t){return function(n,r){r&&(n[e]=t)}},p=function(e,t,n){return function(r,o){if("number"==typeof o||"string"==typeof o)for(var i=0;i0&&(t.style=u),t};t.computeBoxProps=v;var m=function(e){var t=e.textColor||e.color,n=e.backgroundColor;return(0,r.classes)([s(t)&&"color-"+t,s(n)&&"color-bg-"+n])};t.computeBoxClassName=m;var y=function(e){var t=e.as,n=void 0===t?"div":t,r=e.className,a=e.children,c=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}(e,["as","className","children"]);if("function"==typeof a)return a(v(e));var u="string"==typeof r?r+" "+m(c):m(c),s=v(c);return(0,o.createVNode)(i.VNodeFlags.HtmlElement,n,u,a,i.ChildFlags.UnknownChildren,s)};t.Box=y,y.defaultHooks=r.pureComponentHooks},function(e,t,n){"use strict";var r=n(26);e.exports=function(e){return Object(r(e))}},function(e,t,n){"use strict";var r={}.hasOwnProperty;e.exports=function(e,t){return r.call(e,t)}},function(e,t,n){"use strict";var r=n(53),o=n(66),i=n(19),a=n(13),c=n(72),u=[].push,s=function(e){var t=1==e,n=2==e,s=3==e,l=4==e,f=6==e,d=5==e||f;return function(p,h,g,v){for(var m,y,b=i(p),w=o(b),x=r(h,g,3),_=a(w.length),E=0,k=v||c,S=t?k(p,_):n?k(p,0):undefined;_>E;E++)if((d||E in w)&&(y=x(m=w[E],E,b),e))if(t)S[E]=y;else if(y)switch(e){case 3:return!0;case 5:return m;case 6:return E;case 2:u.call(S,m)}else if(l)return!1;return f?-1:s||l?l:S}};e.exports={forEach:s(0),map:s(1),filter:s(2),some:s(3),every:s(4),find:s(5),findIndex:s(6)}},function(e,t,n){"use strict";t.__esModule=!0,t.useSelector=t.useDispatch=t.createAction=t.combineReducers=t.applyMiddleware=t.createStore=void 0;n(0);var r=n(25);function o(e,t){var n;if("undefined"==typeof Symbol||null==e[Symbol.iterator]){if(Array.isArray(e)||(n=function(e,t){if(!e)return;if("string"==typeof e)return i(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return i(e,t)}(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var r=0;return function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}return(n=e[Symbol.iterator]()).next.bind(n)}function i(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n1?o-1:0),a=1;a1?t-1:0),r=1;r2?n-2:0),o=2;o=i){var a=[t].concat(r).map((function(e){return"string"==typeof e?e:e instanceof Error?e.stack||String(e):JSON.stringify(e)})).filter((function(e){return e})).join(" ")+"\nUser Agent: "+navigator.userAgent;Byond.topic({tgui:1,window_id:window.__windowId__,type:"log",ns:t,message:a})}},s=function(e){return{debug:function(){for(var t=arguments.length,n=new Array(t),o=0;o=e.length?{done:!0}:{done:!1,value:e[r++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}return(n=e[Symbol.iterator]()).next.bind(n)}function o(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n1?o-1:0),c=1;c1?r-1:0),i=1;i"+a+""+t+">"}},function(e,t,n){"use strict";var r=n(5);e.exports=function(e){return r((function(){var t=""[e]('"');return t!==t.toLowerCase()||t.split('"').length>3}))}},function(e,t,n){"use strict";t.__esModule=!0,t.formatDb=t.formatMoney=t.formatPower=t.formatSiUnit=void 0;var r=n(9),o=["f","p","n","\u03bc","m"," ","k","M","G","T","P","E","Z","Y"],i=o.indexOf(" "),a=function(e,t,n){if(void 0===t&&(t=-i),void 0===n&&(n=""),"number"!=typeof e||!Number.isFinite(e))return e;var a=Math.floor(Math.log10(e)),c=Math.floor(Math.max(3*t,a)),u=Math.floor(a/3),s=Math.floor(c/3),l=(0,r.clamp)(i+s,0,o.length),f=o[l],d=e/Math.pow(1e3,s),p=u>t?2+3*s-c:0;return((0,r.toFixed)(d,p)+" "+f+n).trim()};t.formatSiUnit=a;t.formatPower=function(e,t){return void 0===t&&(t=0),a(e,t,"W")};t.formatMoney=function(e,t){if(void 0===t&&(t=0),!Number.isFinite(e))return e;var n=(0,r.round)(e,t);t>0&&(n=(0,r.toFixed)(e,t));var o=(n=String(n)).length,i=n.indexOf(".");-1===i&&(i=o);for(var a="",c=0;c0&&c=0?"+":t<0?"\u2013":"",o=Math.abs(t);return n+(o=o===Infinity?"Inf":(0,r.toFixed)(o,2))+" dB"}},function(e,t,n){"use strict";var r={}.toString;e.exports=function(e){return r.call(e).slice(8,-1)}},function(e,t,n){"use strict";var r=Math.ceil,o=Math.floor;e.exports=function(e){return isNaN(e=+e)?0:(e>0?o:r)(e)}},function(e,t,n){"use strict";var r=n(8);e.exports=function(e,t){if(!r(e))return e;var n,o;if(t&&"function"==typeof(n=e.toString)&&!r(o=n.call(e)))return o;if("function"==typeof(n=e.valueOf)&&!r(o=n.call(e)))return o;if(!t&&"function"==typeof(n=e.toString)&&!r(o=n.call(e)))return o;throw TypeError("Can't convert object to primitive value")}},function(e,t,n){"use strict";var r=n(157),o=n(7),i=function(e){return"function"==typeof e?e:undefined};e.exports=function(e,t){return arguments.length<2?i(r[e])||i(o[e]):r[e]&&r[e][t]||o[e]&&o[e][t]}},function(e,t,n){"use strict";var r=n(20),o=n(19),i=n(83),a=n(124),c=i("IE_PROTO"),u=Object.prototype;e.exports=a?Object.getPrototypeOf:function(e){return e=o(e),r(e,c)?e[c]:"function"==typeof e.constructor&&e instanceof e.constructor?e.constructor.prototype:e instanceof Object?u:null}},function(e,t,n){"use strict";t.__esModule=!0,t.getGasColor=t.getGasLabel=t.RADIO_CHANNELS=t.CSS_COLORS=t.COLORS=t.UI_CLOSE=t.UI_DISABLED=t.UI_UPDATE=t.UI_INTERACTIVE=void 0;t.UI_INTERACTIVE=2;t.UI_UPDATE=1;t.UI_DISABLED=0;t.UI_CLOSE=-1;t.COLORS={department:{captain:"#c06616",security:"#e74c3c",medbay:"#3498db",science:"#9b59b6",engineering:"#f1c40f",cargo:"#f39c12",centcom:"#00c100",other:"#c38312"},damageType:{oxy:"#3498db",toxin:"#2ecc71",burn:"#e67e22",brute:"#e74c3c"}};t.CSS_COLORS=["black","white","red","orange","yellow","olive","green","teal","blue","violet","purple","pink","brown","grey","good","average","bad","label"];t.RADIO_CHANNELS=[{name:"Syndicate",freq:1213,color:"#a52a2a"},{name:"Red Team",freq:1215,color:"#ff4444"},{name:"Blue Team",freq:1217,color:"#3434fd"},{name:"CentCom",freq:1337,color:"#2681a5"},{name:"Supply",freq:1347,color:"#b88646"},{name:"Service",freq:1349,color:"#6ca729"},{name:"Science",freq:1351,color:"#c68cfa"},{name:"Command",freq:1353,color:"#5177ff"},{name:"Medical",freq:1355,color:"#57b8f0"},{name:"Engineering",freq:1357,color:"#f37746"},{name:"Security",freq:1359,color:"#dd3535"},{name:"AI Private",freq:1447,color:"#d65d95"},{name:"Common",freq:1459,color:"#1ecc43"}];var r=[{id:"o2",name:"Oxygen",label:"O\u2082",color:"blue"},{id:"n2",name:"Nitrogen",label:"N\u2082",color:"red"},{id:"co2",name:"Carbon Dioxide",label:"CO\u2082",color:"grey"},{id:"plasma",name:"Plasma",label:"Plasma",color:"pink"},{id:"water_vapor",name:"Water Vapor",label:"H\u2082O",color:"grey"},{id:"nob",name:"Hyper-noblium",label:"Hyper-nob",color:"teal"},{id:"n2o",name:"Nitrous Oxide",label:"N\u2082O",color:"red"},{id:"no2",name:"Nitryl",label:"NO\u2082",color:"brown"},{id:"tritium",name:"Tritium",label:"Tritium",color:"green"},{id:"bz",name:"BZ",label:"BZ",color:"purple"},{id:"stim",name:"Stimulum",label:"Stimulum",color:"purple"},{id:"pluox",name:"Pluoxium",label:"Pluoxium",color:"blue"},{id:"miasma",name:"Miasma",label:"Miasma",color:"olive"},{id:"hydrogen",name:"Hydrogen",label:"H\u2082",color:"white"}];t.getGasLabel=function(e,t){var n=String(e).toLowerCase(),o=r.find((function(e){return e.id===n||e.name.toLowerCase()===n}));return o&&o.label||t||e};t.getGasColor=function(e){var t=String(e).toLowerCase(),n=r.find((function(e){return e.id===t||e.name.toLowerCase()===t}));return n&&n.color}},function(e,t,n){"use strict";e.exports=!1},function(e,t,n){"use strict";var r=n(5);e.exports=function(e,t){var n=[][e];return!!n&&r((function(){n.call(null,t||function(){throw 1},1)}))}},function(e,t,n){"use strict";var r=n(11),o=n(29),i=n(15)("species");e.exports=function(e,t){var n,a=r(e).constructor;return a===undefined||(n=r(a)[i])==undefined?t:o(n)}},function(e,t,n){"use strict";var r=n(4),o=n(7),i=n(10),a=n(135),c=n(14),u=n(88),s=n(62),l=n(51),f=n(32),d=n(13),p=n(213),h=n(351),g=n(39),v=n(20),m=n(85),y=n(8),b=n(48),w=n(55),x=n(52).f,_=n(352),E=n(21).forEach,k=n(61),S=n(16),C=n(23),N=n(33),A=n(90),T=N.get,O=N.set,I=S.f,M=C.f,L=Math.round,V=o.RangeError,R=u.ArrayBuffer,P=u.DataView,B=c.NATIVE_ARRAY_BUFFER_VIEWS,D=c.TYPED_ARRAY_TAG,j=c.TypedArray,F=c.TypedArrayPrototype,K=c.aTypedArrayConstructor,z=c.isTypedArray,Y=function(e,t){for(var n=0,r=t.length,o=new(K(e))(r);r>n;)o[n]=t[n++];return o},U=function(e,t){I(e,t,{get:function(){return T(this)[t]}})},$=function(e){var t;return e instanceof R||"ArrayBuffer"==(t=m(e))||"SharedArrayBuffer"==t},H=function(e,t){return z(e)&&"symbol"!=typeof t&&t in e&&String(+t)==String(t)},W=function(e,t){return H(e,t=g(t,!0))?l(2,e[t]):M(e,t)},q=function(e,t,n){return!(H(e,t=g(t,!0))&&y(n)&&v(n,"value"))||v(n,"get")||v(n,"set")||n.configurable||v(n,"writable")&&!n.writable||v(n,"enumerable")&&!n.enumerable?I(e,t,n):(e[t]=n.value,e)};i?(B||(C.f=W,S.f=q,U(F,"buffer"),U(F,"byteOffset"),U(F,"byteLength"),U(F,"length")),r({target:"Object",stat:!0,forced:!B},{getOwnPropertyDescriptor:W,defineProperty:q}),e.exports=function(e,t,n){var i=e.match(/\d+$/)[0]/8,c=e+(n?"Clamped":"")+"Array",u="get"+e,l="set"+e,g=o[c],v=g,m=v&&v.prototype,S={},C=function(e,t){I(e,t,{get:function(){return function(e,t){var n=T(e);return n.view[u](t*i+n.byteOffset,!0)}(this,t)},set:function(e){return function(e,t,r){var o=T(e);n&&(r=(r=L(r))<0?0:r>255?255:255&r),o.view[l](t*i+o.byteOffset,r,!0)}(this,t,e)},enumerable:!0})};B?a&&(v=t((function(e,t,n,r){return s(e,v,c),A(y(t)?$(t)?r!==undefined?new g(t,h(n,i),r):n!==undefined?new g(t,h(n,i)):new g(t):z(t)?Y(v,t):_.call(v,t):new g(p(t)),e,v)})),w&&w(v,j),E(x(g),(function(e){e in v||f(v,e,g[e])})),v.prototype=m):(v=t((function(e,t,n,r){s(e,v,c);var o,a,u,l=0,f=0;if(y(t)){if(!$(t))return z(t)?Y(v,t):_.call(v,t);o=t,f=h(n,i);var g=t.byteLength;if(r===undefined){if(g%i)throw V("Wrong length");if((a=g-f)<0)throw V("Wrong length")}else if((a=d(r)*i)+f>g)throw V("Wrong length");u=a/i}else u=p(t),o=new R(a=u*i);for(O(e,{buffer:o,byteOffset:f,byteLength:a,length:u,view:new P(o)});l"+e+"<\/script>"},h=function(){try{r=document.domain&&new ActiveXObject("htmlfile")}catch(o){}var e,t;h=r?function(e){e.write(p("")),e.close();var t=e.parentWindow.Object;return e=null,t}(r):((t=s("iframe")).style.display="none",u.appendChild(t),t.src=String("javascript:"),(e=t.contentWindow.document).open(),e.write(p("document.F=Object")),e.close(),e.F);for(var n=a.length;n--;)delete h.prototype[a[n]];return h()};c[f]=!0,e.exports=Object.create||function(e,t){var n;return null!==e?(d.prototype=o(e),n=new d,d.prototype=null,n[f]=e):n=h(),t===undefined?n:i(n,t)}},function(e,t,n){"use strict";var r=n(16).f,o=n(20),i=n(15)("toStringTag");e.exports=function(e,t,n){e&&!o(e=n?e:e.prototype,i)&&r(e,i,{configurable:!0,value:t})}},function(e,t,n){"use strict";var r=n(15),o=n(48),i=n(16),a=r("unscopables"),c=Array.prototype;c[a]==undefined&&i.f(c,a,{configurable:!0,value:o(null)}),e.exports=function(e){c[a][e]=!0}},function(e,t,n){"use strict";e.exports=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}}},function(e,t,n){"use strict";var r=n(158),o=n(114).concat("length","prototype");t.f=Object.getOwnPropertyNames||function(e){return r(e,o)}},function(e,t,n){"use strict";var r=n(29);e.exports=function(e,t,n){if(r(e),t===undefined)return e;switch(n){case 0:return function(){return e.call(t)};case 1:return function(n){return e.call(t,n)};case 2:return function(n,r){return e.call(t,n,r)};case 3:return function(n,r,o){return e.call(t,n,r,o)}}return function(){return e.apply(t,arguments)}}},function(e,t,n){"use strict";var r=n(39),o=n(16),i=n(51);e.exports=function(e,t,n){var a=r(t);a in e?o.f(e,a,i(0,n)):e[a]=n}},function(e,t,n){"use strict";var r=n(11),o=n(197);e.exports=Object.setPrototypeOf||("__proto__"in{}?function(){var e,t=!1,n={};try{(e=Object.getOwnPropertyDescriptor(Object.prototype,"__proto__").set).call(n,[]),t=n instanceof Array}catch(i){}return function(n,i){return r(n),o(i),t?e.call(n,i):n.__proto__=i,n}}():undefined)},function(e,t,n){"use strict";var r=n(68),o=n(8),i=n(20),a=n(16).f,c=n(67),u=n(76),s=c("meta"),l=0,f=Object.isExtensible||function(){return!0},d=function(e){a(e,s,{value:{objectID:"O"+ ++l,weakData:{}}})},p=e.exports={REQUIRED:!1,fastKey:function(e,t){if(!o(e))return"symbol"==typeof e?e:("string"==typeof e?"S":"P")+e;if(!i(e,s)){if(!f(e))return"F";if(!t)return"E";d(e)}return e[s].objectID},getWeakData:function(e,t){if(!i(e,s)){if(!f(e))return!0;if(!t)return!1;d(e)}return e[s].weakData},onFreeze:function(e){return u&&p.REQUIRED&&f(e)&&!i(e,s)&&d(e),e}};r[s]=!0},function(e,t,n){"use strict";t.__esModule=!0,t.removeScrollableNode=t.addScrollableNode=t.canStealFocus=t.setupGlobalEvents=t.globalEvents=void 0;var r=n(398),o=n(78),i=(n(24),new r.EventEmitter);t.globalEvents=i;var a,c=!1;t.setupGlobalEvents=function(e){void 0===e&&(e={}),c=!!e.ignoreWindowFocus};var u=!0,s=function y(e,t){c?u=!0:(a&&(clearTimeout(a),a=null),t?a=setTimeout((function(){return y(e)})):u!==e&&(u=e,i.emit(e?"window-focus":"window-blur"),i.emit("window-focus-change",e)))},l=null,f=function(e){var t=String(e.tagName).toLowerCase();return"input"===t||"textarea"===t};t.canStealFocus=f;var d=function b(){l&&(l.removeEventListener("blur",b),l=null)},p=null,h=null,g=[];t.addScrollableNode=function(e){g.push(e)};t.removeScrollableNode=function(e){var t=g.indexOf(e);t>=0&&g.splice(t,1)};window.addEventListener("mousemove",(function(e){var t=e.target;t!==h&&(h=t,function(e){if(!l&&u)for(var t=document.body;e&&e!==t;){if(g.includes(e)){if(e.contains(p))return;return p=e,void e.focus()}e=e.parentNode}}(t))})),window.addEventListener("focusin",(function(e){if(h=null,p=e.target,s(!0),f(e.target))return t=e.target,d(),void(l=t).addEventListener("blur",d);var t})),window.addEventListener("focusout",(function(e){h=null,s(!1,!0)})),window.addEventListener("blur",(function(e){h=null,s(!1,!0)})),window.addEventListener("beforeunload",(function(e){s(!1)}));var v={},m=function(){function e(e,t,n){this.event=e,this.type=t,this.code=window.event?e.which:e.keyCode,this.ctrl=e.ctrlKey,this.shift=e.shiftKey,this.alt=e.altKey,this.repeat=!!n}var t=e.prototype;return t.hasModifierKeys=function(){return this.ctrl||this.alt||this.shift},t.isModifierKey=function(){return this.code===o.KEY_CTRL||this.code===o.KEY_SHIFT||this.code===o.KEY_ALT},t.isDown=function(){return"keydown"===this.type},t.isUp=function(){return"keyup"===this.type},t.toString=function(){return this._str||(this._str="",this.ctrl&&(this._str+="Ctrl+"),this.alt&&(this._str+="Alt+"),this.shift&&(this._str+="Shift+"),this.code>=48&&this.code<=90?this._str+=String.fromCharCode(this.code):this.code>=o.KEY_F1&&this.code<=o.KEY_F12?this._str+="F"+(this.code-111):this._str+="["+this.code+"]"),this._str},e}();document.addEventListener("keydown",(function(e){if(!f(e.target)){var t=e.keyCode,n=new m(e,"keydown",v[t]);i.emit("keydown",n),i.emit("key",n),v[t]=!0}})),document.addEventListener("keyup",(function(e){if(!f(e.target)){var t=e.keyCode,n=new m(e,"keyup");i.emit("keyup",n),i.emit("key",n),v[t]=!1}}))},function(e,t,n){"use strict";t.__esModule=!0,t.assetMiddleware=t.resolveAsset=t.loadStyleSheet=void 0;var r=n(458),o=(0,n(24).createLogger)("assets"),i=[/v4shim/i],a={},c={},u=function l(e,t){if(void 0===t&&(t=1),!a[e]){a[e]=!0,o.log("loading stylesheet '"+e+"'");var n=(0,r.loadCSS)(e);n.addEventListener("load",(function(){if(!s(n,e))return n.parentNode.removeChild(n),n=null,a[e]=null,t>=5?void o.error("Error: Failed to load the stylesheet '"+e+"' after 5 attempts.\nIt was either not found, or you're trying to load an empty stylesheet that has no CSS rules in it."):void setTimeout((function(){return l(e,t+1)}),3e3)}))}};t.loadStyleSheet=u;var s=function(e,t){var n=e.sheet;return n?0!==n.rules.length:(o.warn("Warning: stylesheet '"+t+"' was not found in the DOM"),!1)};t.resolveAsset=function(e){return c[e]||e};t.assetMiddleware=function(e){return function(e){return function(t){var n=t.type,r=t.payload;if("asset/stylesheet"!==n)if("asset/mappings"!==n)e(t);else for(var o=function(){var e=s[a];if(i.some((function(t){return t.test(e)})))return"continue";var t=r[e],n=e.split(".").pop();c[e]=t,"css"===n&&u(t)},a=0,s=Object.keys(r);ah;h++)if((v=l?b(r(y=e[h])[0],y[1]):b(e[h]))&&v instanceof s)return v;return new s(!1)}d=p.call(e)}for(m=d.next;!(y=m.call(d)).done;)if("object"==typeof(v=u(d,b,y.value,l))&&v&&v instanceof s)return v;return new s(!1)}).stop=function(e){return new s(!0,e)}},function(e,t,n){"use strict";var r=n(26),o="["+n(92)+"]",i=RegExp("^"+o+o+"*"),a=RegExp(o+o+"*$"),c=function(e){return function(t){var n=String(r(t));return 1&e&&(n=n.replace(i,"")),2&e&&(n=n.replace(a,"")),n}};e.exports={start:c(1),end:c(2),trim:c(3)}},,function(e,t,n){"use strict";var r=n(5),o=n(37),i="".split;e.exports=r((function(){return!Object("z").propertyIsEnumerable(0)}))?function(e){return"String"==o(e)?i.call(e,""):Object(e)}:Object},function(e,t,n){"use strict";var r=0,o=Math.random();e.exports=function(e){return"Symbol("+String(e===undefined?"":e)+")_"+(++r+o).toString(36)}},function(e,t,n){"use strict";e.exports={}},function(e,t,n){"use strict";var r=n(31),o=n(13),i=n(47),a=function(e){return function(t,n,a){var c,u=r(t),s=o(u.length),l=i(a,s);if(e&&n!=n){for(;s>l;)if((c=u[l++])!=c)return!0}else for(;s>l;l++)if((e||l in u)&&u[l]===n)return e||l||0;return!e&&-1}};e.exports={includes:a(!0),indexOf:a(!1)}},function(e,t,n){"use strict";var r=n(5),o=/#|\.prototype\./,i=function(e,t){var n=c[a(e)];return n==s||n!=u&&("function"==typeof t?r(t):!!t)},a=i.normalize=function(e){return String(e).replace(o,".").toLowerCase()},c=i.data={},u=i.NATIVE="N",s=i.POLYFILL="P";e.exports=i},function(e,t,n){"use strict";var r=n(158),o=n(114);e.exports=Object.keys||function(e){return r(e,o)}},function(e,t,n){"use strict";var r=n(8),o=n(60),i=n(15)("species");e.exports=function(e,t){var n;return o(e)&&("function"!=typeof(n=e.constructor)||n!==Array&&!o(n.prototype)?r(n)&&null===(n=n[i])&&(n=undefined):n=undefined),new(n===undefined?Array:n)(0===t?0:t)}},function(e,t,n){"use strict";var r=n(5),o=n(15),i=n(117),a=o("species");e.exports=function(e){return i>=51||!r((function(){var t=[];return(t.constructor={})[a]=function(){return{foo:1}},1!==t[e](Boolean).foo}))}},function(e,t,n){"use strict";e.exports={}},function(e,t,n){"use strict";var r=n(27);e.exports=function(e,t,n){for(var o in t)r(e,o,t[o],n);return e}},function(e,t,n){"use strict";var r=n(5);e.exports=!r((function(){return Object.isExtensible(Object.preventExtensions({}))}))},function(e,t,n){"use strict";var r=n(11);e.exports=function(){var e=r(this),t="";return e.global&&(t+="g"),e.ignoreCase&&(t+="i"),e.multiline&&(t+="m"),e.dotAll&&(t+="s"),e.unicode&&(t+="u"),e.sticky&&(t+="y"),t}},function(e,t,n){"use strict";t.__esModule=!0,t.KEY_QUOTE=t.KEY_RIGHT_BRACKET=t.KEY_BACKSLASH=t.KEY_LEFT_BRACKET=t.KEY_SLASH=t.KEY_PERIOD=t.KEY_MINUS=t.KEY_COMMA=t.KEY_EQUAL=t.KEY_SEMICOLON=t.KEY_F12=t.KEY_F11=t.KEY_F10=t.KEY_F9=t.KEY_F8=t.KEY_F7=t.KEY_F6=t.KEY_F5=t.KEY_F4=t.KEY_F3=t.KEY_F2=t.KEY_F1=t.KEY_Z=t.KEY_Y=t.KEY_X=t.KEY_W=t.KEY_V=t.KEY_U=t.KEY_T=t.KEY_S=t.KEY_R=t.KEY_Q=t.KEY_P=t.KEY_O=t.KEY_N=t.KEY_M=t.KEY_L=t.KEY_K=t.KEY_J=t.KEY_I=t.KEY_H=t.KEY_G=t.KEY_F=t.KEY_E=t.KEY_D=t.KEY_C=t.KEY_B=t.KEY_A=t.KEY_9=t.KEY_8=t.KEY_7=t.KEY_6=t.KEY_5=t.KEY_4=t.KEY_3=t.KEY_2=t.KEY_1=t.KEY_0=t.KEY_DELETE=t.KEY_INSERT=t.KEY_DOWN=t.KEY_RIGHT=t.KEY_UP=t.KEY_LEFT=t.KEY_HOME=t.KEY_END=t.KEY_PAGEDOWN=t.KEY_PAGEUP=t.KEY_SPACE=t.KEY_ESCAPE=t.KEY_CAPSLOCK=t.KEY_PAUSE=t.KEY_ALT=t.KEY_CTRL=t.KEY_SHIFT=t.KEY_ENTER=t.KEY_TAB=t.KEY_BACKSPACE=void 0;t.KEY_BACKSPACE=8;t.KEY_TAB=9;t.KEY_ENTER=13;t.KEY_SHIFT=16;t.KEY_CTRL=17;t.KEY_ALT=18;t.KEY_PAUSE=19;t.KEY_CAPSLOCK=20;t.KEY_ESCAPE=27;t.KEY_SPACE=32;t.KEY_PAGEUP=33;t.KEY_PAGEDOWN=34;t.KEY_END=35;t.KEY_HOME=36;t.KEY_LEFT=37;t.KEY_UP=38;t.KEY_RIGHT=39;t.KEY_DOWN=40;t.KEY_INSERT=45;t.KEY_DELETE=46;t.KEY_0=48;t.KEY_1=49;t.KEY_2=50;t.KEY_3=51;t.KEY_4=52;t.KEY_5=53;t.KEY_6=54;t.KEY_7=55;t.KEY_8=56;t.KEY_9=57;t.KEY_A=65;t.KEY_B=66;t.KEY_C=67;t.KEY_D=68;t.KEY_E=69;t.KEY_F=70;t.KEY_G=71;t.KEY_H=72;t.KEY_I=73;t.KEY_J=74;t.KEY_K=75;t.KEY_L=76;t.KEY_M=77;t.KEY_N=78;t.KEY_O=79;t.KEY_P=80;t.KEY_Q=81;t.KEY_R=82;t.KEY_S=83;t.KEY_T=84;t.KEY_U=85;t.KEY_V=86;t.KEY_W=87;t.KEY_X=88;t.KEY_Y=89;t.KEY_Z=90;t.KEY_F1=112;t.KEY_F2=113;t.KEY_F3=114;t.KEY_F4=115;t.KEY_F5=116;t.KEY_F6=117;t.KEY_F7=118;t.KEY_F8=119;t.KEY_F9=120;t.KEY_F10=121;t.KEY_F11=122;t.KEY_F12=123;t.KEY_SEMICOLON=186;t.KEY_EQUAL=187;t.KEY_COMMA=188;t.KEY_MINUS=189;t.KEY_PERIOD=190;t.KEY_SLASH=191;t.KEY_LEFT_BRACKET=219;t.KEY_BACKSLASH=220;t.KEY_RIGHT_BRACKET=221;t.KEY_QUOTE=222},function(e,t,n){"use strict";t.__esModule=!0,t.vecNormalize=t.vecLength=t.vecInverse=t.vecScale=t.vecDivide=t.vecMultiply=t.vecSubtract=t.vecAdd=void 0;var r=n(12),o=function(e,t){return e+t},i=function(e,t){return e-t},a=function(e,t){return e*t},c=function(e,t){return e/t};t.vecAdd=function(){for(var e=arguments.length,t=new Array(e),n=0;n=0:f>d;d+=p)d in l&&(u=n(u,l[d],d,s));return u}};e.exports={left:c(!1),right:c(!0)}},function(e,t,n){"use strict";var r=n(7),o=n(10),i=n(125),a=n(32),c=n(75),u=n(5),s=n(62),l=n(38),f=n(13),d=n(213),p=n(436),h=n(41),g=n(55),v=n(52).f,m=n(16).f,y=n(118),b=n(49),w=n(33),x=w.get,_=w.set,E=r.ArrayBuffer,k=E,S=r.DataView,C=S&&S.prototype,N=Object.prototype,A=r.RangeError,T=p.pack,O=p.unpack,I=function(e){return[255&e]},M=function(e){return[255&e,e>>8&255]},L=function(e){return[255&e,e>>8&255,e>>16&255,e>>24&255]},V=function(e){return e[3]<<24|e[2]<<16|e[1]<<8|e[0]},R=function(e){return T(e,23,4)},P=function(e){return T(e,52,8)},B=function(e,t){m(e.prototype,t,{get:function(){return x(this)[t]}})},D=function(e,t,n,r){var o=d(n),i=x(e);if(o+t>i.byteLength)throw A("Wrong index");var a=x(i.buffer).bytes,c=o+i.byteOffset,u=a.slice(c,c+t);return r?u:u.reverse()},j=function(e,t,n,r,o,i){var a=d(n),c=x(e);if(a+t>c.byteLength)throw A("Wrong index");for(var u=x(c.buffer).bytes,s=a+c.byteOffset,l=r(+o),f=0;fY;)(F=z[Y++])in k||a(k,F,E[F]);K.constructor=k}g&&h(C)!==N&&g(C,N);var U=new S(new k(2)),$=C.setInt8;U.setInt8(0,2147483648),U.setInt8(1,2147483649),!U.getInt8(0)&&U.getInt8(1)||c(C,{setInt8:function(e,t){$.call(this,e,t<<24>>24)},setUint8:function(e,t){$.call(this,e,t<<24>>24)}},{unsafe:!0})}else k=function(e){s(this,k,"ArrayBuffer");var t=d(e);_(this,{bytes:y.call(new Array(t),0),byteLength:t}),o||(this.byteLength=t)},S=function(e,t,n){s(this,S,"DataView"),s(e,k,"DataView");var r=x(e).byteLength,i=l(t);if(i<0||i>r)throw A("Wrong offset");if(i+(n=n===undefined?r-i:f(n))>r)throw A("Wrong length");_(this,{buffer:e,byteLength:n,byteOffset:i}),o||(this.buffer=e,this.byteLength=n,this.byteOffset=i)},o&&(B(k,"byteLength"),B(S,"buffer"),B(S,"byteLength"),B(S,"byteOffset")),c(S.prototype,{getInt8:function(e){return D(this,1,e)[0]<<24>>24},getUint8:function(e){return D(this,1,e)[0]},getInt16:function(e){var t=D(this,2,e,arguments.length>1?arguments[1]:undefined);return(t[1]<<8|t[0])<<16>>16},getUint16:function(e){var t=D(this,2,e,arguments.length>1?arguments[1]:undefined);return t[1]<<8|t[0]},getInt32:function(e){return V(D(this,4,e,arguments.length>1?arguments[1]:undefined))},getUint32:function(e){return V(D(this,4,e,arguments.length>1?arguments[1]:undefined))>>>0},getFloat32:function(e){return O(D(this,4,e,arguments.length>1?arguments[1]:undefined),23)},getFloat64:function(e){return O(D(this,8,e,arguments.length>1?arguments[1]:undefined),52)},setInt8:function(e,t){j(this,1,e,I,t)},setUint8:function(e,t){j(this,1,e,I,t)},setInt16:function(e,t){j(this,2,e,M,t,arguments.length>2?arguments[2]:undefined)},setUint16:function(e,t){j(this,2,e,M,t,arguments.length>2?arguments[2]:undefined)},setInt32:function(e,t){j(this,4,e,L,t,arguments.length>2?arguments[2]:undefined)},setUint32:function(e,t){j(this,4,e,L,t,arguments.length>2?arguments[2]:undefined)},setFloat32:function(e,t){j(this,4,e,R,t,arguments.length>2?arguments[2]:undefined)},setFloat64:function(e,t){j(this,8,e,P,t,arguments.length>2?arguments[2]:undefined)}});b(k,"ArrayBuffer"),b(S,"DataView"),e.exports={ArrayBuffer:k,DataView:S}},function(e,t,n){"use strict";var r=n(4),o=n(7),i=n(70),a=n(27),c=n(56),u=n(63),s=n(62),l=n(8),f=n(5),d=n(86),p=n(49),h=n(90);e.exports=function(e,t,n){var g=-1!==e.indexOf("Map"),v=-1!==e.indexOf("Weak"),m=g?"set":"add",y=o[e],b=y&&y.prototype,w=y,x={},_=function(e){var t=b[e];a(b,e,"add"==e?function(e){return t.call(this,0===e?0:e),this}:"delete"==e?function(e){return!(v&&!l(e))&&t.call(this,0===e?0:e)}:"get"==e?function(e){return v&&!l(e)?undefined:t.call(this,0===e?0:e)}:"has"==e?function(e){return!(v&&!l(e))&&t.call(this,0===e?0:e)}:function(e,n){return t.call(this,0===e?0:e,n),this})};if(i(e,"function"!=typeof y||!(v||b.forEach&&!f((function(){(new y).entries().next()})))))w=n.getConstructor(t,e,g,m),c.REQUIRED=!0;else if(i(e,!0)){var E=new w,k=E[m](v?{}:-0,1)!=E,S=f((function(){E.has(1)})),C=d((function(e){new y(e)})),N=!v&&f((function(){for(var e=new y,t=5;t--;)e[m](t,t);return!e.has(-0)}));C||((w=t((function(t,n){s(t,w,e);var r=h(new y,t,w);return n!=undefined&&u(n,r[m],r,g),r}))).prototype=b,b.constructor=w),(S||N)&&(_("delete"),_("has"),g&&_("get")),(N||k)&&_(m),v&&b.clear&&delete b.clear}return x[e]=w,r({global:!0,forced:w!=y},x),p(w,e),v||n.setStrong(w,e,g),w}},function(e,t,n){"use strict";var r=n(8),o=n(55);e.exports=function(e,t,n){var i,a;return o&&"function"==typeof(i=t.constructor)&&i!==n&&r(a=i.prototype)&&a!==n.prototype&&o(e,a),e}},function(e,t,n){"use strict";var r=Math.expm1,o=Math.exp;e.exports=!r||r(10)>22025.465794806718||r(10)<22025.465794806718||-2e-17!=r(-2e-17)?function(e){return 0==(e=+e)?e:e>-1e-6&&e<1e-6?e+e*e/2:o(e)-1}:r},function(e,t,n){"use strict";e.exports="\t\n\x0B\f\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\ufeff"},function(e,t,n){"use strict";var r=n(43),o=n(7),i=n(5);e.exports=r||!i((function(){var e=Math.random();__defineSetter__.call(null,e,(function(){})),delete o[e]}))},function(e,t,n){"use strict";var r=n(8),o=n(37),i=n(15)("match");e.exports=function(e){var t;return r(e)&&((t=e[i])!==undefined?!!t:"RegExp"==o(e))}},function(e,t,n){"use strict";var r=n(5);function o(e,t){return RegExp(e,t)}t.UNSUPPORTED_Y=r((function(){var e=o("a","y");return e.lastIndex=2,null!=e.exec("abcd")})),t.BROKEN_CARET=r((function(){var e=o("^r","gy");return e.lastIndex=2,null!=e.exec("str")}))},function(e,t,n){"use strict";var r=n(4),o=n(97);r({target:"RegExp",proto:!0,forced:/./.exec!==o},{exec:o})},function(e,t,n){"use strict";var r,o,i=n(77),a=n(95),c=RegExp.prototype.exec,u=String.prototype.replace,s=c,l=(r=/a/,o=/b*/g,c.call(r,"a"),c.call(o,"a"),0!==r.lastIndex||0!==o.lastIndex),f=a.UNSUPPORTED_Y||a.BROKEN_CARET,d=/()??/.exec("")[1]!==undefined;(l||d||f)&&(s=function(e){var t,n,r,o,a=this,s=f&&a.sticky,p=i.call(a),h=a.source,g=0,v=e;return s&&(-1===(p=p.replace("y","")).indexOf("g")&&(p+="g"),v=String(e).slice(a.lastIndex),a.lastIndex>0&&(!a.multiline||a.multiline&&"\n"!==e[a.lastIndex-1])&&(h="(?: "+h+")",v=" "+v,g++),n=new RegExp("^(?:"+h+")",p)),d&&(n=new RegExp("^"+h+"$(?!\\s)",p)),l&&(t=a.lastIndex),r=c.call(s?n:a,v),s?r?(r.input=r.input.slice(g),r[0]=r[0].slice(g),r.index=a.lastIndex,a.lastIndex+=r[0].length):a.lastIndex=0:l&&r&&(a.lastIndex=a.global?r.index+r[0].length:t),d&&r&&r.length>1&&u.call(r[0],n,(function(){for(o=1;o")})),l="$0"==="a".replace(/./,"$0"),f=i("replace"),d=!!/./[f]&&""===/./[f]("a","$0"),p=!o((function(){var e=/(?:)/,t=e.exec;e.exec=function(){return t.apply(this,arguments)};var n="ab".split(e);return 2!==n.length||"a"!==n[0]||"b"!==n[1]}));e.exports=function(e,t,n,f){var h=i(e),g=!o((function(){var t={};return t[h]=function(){return 7},7!=""[e](t)})),v=g&&!o((function(){var t=!1,n=/a/;return"split"===e&&((n={}).constructor={},n.constructor[u]=function(){return n},n.flags="",n[h]=/./[h]),n.exec=function(){return t=!0,null},n[h](""),!t}));if(!g||!v||"replace"===e&&(!s||!l||d)||"split"===e&&!p){var m=/./[h],y=n(h,""[e],(function(e,t,n,r,o){return t.exec===a?g&&!o?{done:!0,value:m.call(t,n,r)}:{done:!0,value:e.call(n,t,r)}:{done:!1}}),{REPLACE_KEEPS_$0:l,REGEXP_REPLACE_SUBSTITUTES_UNDEFINED_CAPTURE:d}),b=y[0],w=y[1];r(String.prototype,e,b),r(RegExp.prototype,h,2==t?function(e,t){return w.call(e,this,t)}:function(e){return w.call(e,this)})}f&&c(RegExp.prototype[h],"sham",!0)}},function(e,t,n){"use strict";var r=n(131).charAt;e.exports=function(e,t,n){return t+(n?r(e,t).length:1)}},function(e,t,n){"use strict";var r=n(37),o=n(97);e.exports=function(e,t){var n=e.exec;if("function"==typeof n){var i=n.call(e,t);if("object"!=typeof i)throw TypeError("RegExp exec method returned something other than an Object or null");return i}if("RegExp"!==r(e))throw TypeError("RegExp#exec called on incompatible receiver");return o.call(e,t)}},function(e,t,n){"use strict";var r;t.__esModule=!0,t.perf=void 0;null==(r=window.performance)||r.now;var o={mark:function(e,t){0},measure:function(e,t){}};t.perf=o},function(e,t,n){"use strict";t.__esModule=!0,t.setupHotReloading=t.sendLogEntry=t.sendMessage=t.subscribe=void 0;var r=[];t.subscribe=function(e){return r.push(e)};t.sendMessage=function(e){};t.sendLogEntry=function(e,t){};t.setupHotReloading=function(){0}},function(e,t,n){"use strict";(function(e){var r=void 0!==e&&e||"undefined"!=typeof self&&self||window,o=Function.prototype.apply;function i(e,t){this._id=e,this._clearFn=t}t.setTimeout=function(){return new i(o.call(setTimeout,r,arguments),clearTimeout)},t.setInterval=function(){return new i(o.call(setInterval,r,arguments),clearInterval)},t.clearTimeout=t.clearInterval=function(e){e&&e.close()},i.prototype.unref=i.prototype.ref=function(){},i.prototype.close=function(){this._clearFn.call(r,this._id)},t.enroll=function(e,t){clearTimeout(e._idleTimeoutId),e._idleTimeout=t},t.unenroll=function(e){clearTimeout(e._idleTimeoutId),e._idleTimeout=-1},t._unrefActive=t.active=function(e){clearTimeout(e._idleTimeoutId);var t=e._idleTimeout;t>=0&&(e._idleTimeoutId=setTimeout((function(){e._onTimeout&&e._onTimeout()}),t))},n(459),t.setImmediate="undefined"!=typeof self&&self.setImmediate||void 0!==e&&e.setImmediate||void 0,t.clearImmediate="undefined"!=typeof self&&self.clearImmediate||void 0!==e&&e.clearImmediate||void 0}).call(this,n(81))},function(e,t,n){"use strict";function r(e,t,n,r,o,i,a){try{var c=e[i](a),u=c.value}catch(s){return void n(s)}c.done?t(u):Promise.resolve(u).then(r,o)}function o(e){return function(){var t=this,n=arguments;return new Promise((function(o,i){var a=e.apply(t,n);function c(e){r(a,o,i,c,u,"next",e)}function u(e){r(a,o,i,c,u,"throw",e)}c(undefined)}))}}t.__esModule=!0,t.storage=t.IMPL_INDEXED_DB=t.IMPL_LOCAL_STORAGE=t.IMPL_MEMORY=void 0;t.IMPL_MEMORY=0;t.IMPL_LOCAL_STORAGE=1;t.IMPL_INDEXED_DB=2;var i=function(e){return function(){try{return Boolean(e())}catch(t){return!1}}},a=i((function(){return window.localStorage&&window.localStorage.getItem})),c=i((function(){return(window.indexedDB||window.msIndexedDB)&&(window.IDBTransaction||window.msIDBTransaction)})),u=function(){function e(){this.impl=0,this.store={}}var t=e.prototype;return t.get=function(e){return this.store[e]},t.set=function(e,t){this.store[e]=t},t.remove=function(e){this.store[e]=undefined},t.clear=function(){this.store={}},e}(),s=function(){function e(){this.impl=1}var t=e.prototype;return t.get=function(e){var t=localStorage.getItem(e);if("string"==typeof t)return JSON.parse(t)},t.set=function(e,t){localStorage.setItem(e,JSON.stringify(t))},t.remove=function(e){localStorage.removeItem(e)},t.clear=function(){localStorage.clear()},e}(),l=function(){function e(){this.impl=2,this.dbPromise=new Promise((function(e,t){var n=(window.indexedDB||window.msIndexedDB).open("tgui",1);n.onupgradeneeded=function(){try{n.result.createObjectStore("storage-v1")}catch(e){t(new Error("Failed to upgrade IDB: "+n.error))}},n.onsuccess=function(){return e(n.result)},n.onerror=function(){t(new Error("Failed to open IDB: "+n.error))}}))}var t=e.prototype;return t.getStore=function(e){return this.dbPromise.then((function(t){return t.transaction("storage-v1",e).objectStore("storage-v1")}))},t.get=function(){var e=o(regeneratorRuntime.mark((function t(e){var n;return regeneratorRuntime.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,this.getStore("readonly");case 2:return n=t.sent,t.abrupt("return",new Promise((function(t,r){var o=n.get(e);o.onsuccess=function(){return t(o.result)},o.onerror=function(){return r(o.error)}})));case 4:case"end":return t.stop()}}),t,this)})));return function(t){return e.apply(this,arguments)}}(),t.set=function(){var e=o(regeneratorRuntime.mark((function t(e,n){return regeneratorRuntime.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return null===n&&(n=undefined),t.next=3,this.getStore("readwrite");case 3:t.sent.put(n,e);case 5:case"end":return t.stop()}}),t,this)})));return function(t,n){return e.apply(this,arguments)}}(),t.remove=function(){var e=o(regeneratorRuntime.mark((function t(e){return regeneratorRuntime.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,this.getStore("readwrite");case 2:t.sent["delete"](e);case 4:case"end":return t.stop()}}),t,this)})));return function(t){return e.apply(this,arguments)}}(),t.clear=function(){var e=o(regeneratorRuntime.mark((function t(){return regeneratorRuntime.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.next=2,this.getStore("readwrite");case 2:e.sent.clear();case 4:case"end":return e.stop()}}),t,this)})));return function(){return e.apply(this,arguments)}}(),e}(),f=new(function(){function e(){this.backendPromise=o(regeneratorRuntime.mark((function e(){var t;return regeneratorRuntime.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(!c()){e.next=10;break}return e.prev=1,t=new l,e.next=5,t.dbPromise;case 5:return e.abrupt("return",t);case 8:e.prev=8,e.t0=e["catch"](1);case 10:if(!a()){e.next=12;break}return e.abrupt("return",new s);case 12:return e.abrupt("return",new u);case 13:case"end":return e.stop()}}),e,null,[[1,8]])})))()}var t=e.prototype;return t.get=function(){var e=o(regeneratorRuntime.mark((function t(e){var n;return regeneratorRuntime.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,this.backendPromise;case 2:return n=t.sent,t.abrupt("return",n.get(e));case 4:case"end":return t.stop()}}),t,this)})));return function(t){return e.apply(this,arguments)}}(),t.set=function(){var e=o(regeneratorRuntime.mark((function t(e,n){var r;return regeneratorRuntime.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,this.backendPromise;case 2:return r=t.sent,t.abrupt("return",r.set(e,n));case 4:case"end":return t.stop()}}),t,this)})));return function(t,n){return e.apply(this,arguments)}}(),t.remove=function(){var e=o(regeneratorRuntime.mark((function t(e){var n;return regeneratorRuntime.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,this.backendPromise;case 2:return n=t.sent,t.abrupt("return",n.remove(e));case 4:case"end":return t.stop()}}),t,this)})));return function(t){return e.apply(this,arguments)}}(),t.clear=function(){var e=o(regeneratorRuntime.mark((function t(){var e;return regeneratorRuntime.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,this.backendPromise;case 2:return e=t.sent,t.abrupt("return",e.clear());case 4:case"end":return t.stop()}}),t,this)})));return function(){return e.apply(this,arguments)}}(),e}());t.storage=f},function(e,t,n){"use strict";t.__esModule=!0,t.Icon=void 0;var r=n(0),o=n(6),i=n(18);var a=/-o$/,c=function(e){var t=e.name,n=e.size,c=e.spin,u=e.className,s=e.style,l=void 0===s?{}:s,f=e.rotation,d=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}(e,["name","size","spin","className","style","rotation"]);n&&(l["font-size"]=100*n+"%"),"number"==typeof f&&(l.transform="rotate("+f+"deg)");var p=a.test(t),h=t.replace(a,"");return(0,r.normalizeProps)((0,r.createComponentVNode)(2,i.Box,Object.assign({as:"i",className:(0,o.classes)([u,p?"far":"fas","fa-"+h,c&&"fa-spin"]),style:l},d)))};t.Icon=c,c.defaultHooks=o.pureComponentHooks},,,,function(e,t,n){"use strict";var r=n(7),o=n(8),i=r.document,a=o(i)&&o(i.createElement);e.exports=function(e){return a?i.createElement(e):{}}},function(e,t,n){"use strict";var r=n(7),o=n(32);e.exports=function(e,t){try{o(r,e,t)}catch(n){r[e]=t}return t}},function(e,t,n){"use strict";var r=n(154),o=Function.toString;"function"!=typeof r.inspectSource&&(r.inspectSource=function(e){return o.call(e)}),e.exports=r.inspectSource},function(e,t,n){"use strict";var r=n(43),o=n(154);(e.exports=function(e,t){return o[e]||(o[e]=t!==undefined?t:{})})("versions",[]).push({version:"3.6.5",mode:r?"pure":"global",copyright:"\xa9 2020 Denis Pushkarev (zloirock.ru)"})},function(e,t,n){"use strict";var r=n(40),o=n(52),i=n(115),a=n(11);e.exports=r("Reflect","ownKeys")||function(e){var t=o.f(a(e)),n=i.f;return n?t.concat(n(e)):t}},function(e,t,n){"use strict";e.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]},function(e,t,n){"use strict";t.f=Object.getOwnPropertySymbols},function(e,t,n){"use strict";var r=n(5);e.exports=!!Object.getOwnPropertySymbols&&!r((function(){return!String(Symbol())}))},function(e,t,n){"use strict";var r,o,i=n(7),a=n(84),c=i.process,u=c&&c.versions,s=u&&u.v8;s?o=(r=s.split("."))[0]+r[1]:a&&(!(r=a.match(/Edge\/(\d+)/))||r[1]>=74)&&(r=a.match(/Chrome\/(\d+)/))&&(o=r[1]),e.exports=o&&+o},function(e,t,n){"use strict";var r=n(19),o=n(47),i=n(13);e.exports=function(e){for(var t=r(this),n=i(t.length),a=arguments.length,c=o(a>1?arguments[1]:undefined,n),u=a>2?arguments[2]:undefined,s=u===undefined?n:o(u,n);s>c;)t[c++]=e;return t}},function(e,t,n){"use strict";var r=n(15),o=n(74),i=r("iterator"),a=Array.prototype;e.exports=function(e){return e!==undefined&&(o.Array===e||a[i]===e)}},function(e,t,n){"use strict";var r=n(85),o=n(74),i=n(15)("iterator");e.exports=function(e){if(e!=undefined)return e[i]||e["@@iterator"]||o[r(e)]}},function(e,t,n){"use strict";var r={};r[n(15)("toStringTag")]="z",e.exports="[object z]"===String(r)},function(e,t,n){"use strict";var r=n(31),o=n(50),i=n(74),a=n(33),c=n(123),u=a.set,s=a.getterFor("Array Iterator");e.exports=c(Array,"Array",(function(e,t){u(this,{type:"Array Iterator",target:r(e),index:0,kind:t})}),(function(){var e=s(this),t=e.target,n=e.kind,r=e.index++;return!t||r>=t.length?(e.target=undefined,{value:undefined,done:!0}):"keys"==n?{value:r,done:!1}:"values"==n?{value:t[r],done:!1}:{value:[r,t[r]],done:!1}}),"values"),i.Arguments=i.Array,o("keys"),o("values"),o("entries")},function(e,t,n){"use strict";var r=n(4),o=n(195),i=n(41),a=n(55),c=n(49),u=n(32),s=n(27),l=n(15),f=n(43),d=n(74),p=n(196),h=p.IteratorPrototype,g=p.BUGGY_SAFARI_ITERATORS,v=l("iterator"),m=function(){return this};e.exports=function(e,t,n,l,p,y,b){o(n,t,l);var w,x,_,E=function(e){if(e===p&&A)return A;if(!g&&e in C)return C[e];switch(e){case"keys":case"values":case"entries":return function(){return new n(this,e)}}return function(){return new n(this)}},k=t+" Iterator",S=!1,C=e.prototype,N=C[v]||C["@@iterator"]||p&&C[p],A=!g&&N||E(p),T="Array"==t&&C.entries||N;if(T&&(w=i(T.call(new e)),h!==Object.prototype&&w.next&&(f||i(w)===h||(a?a(w,h):"function"!=typeof w[v]&&u(w,v,m)),c(w,k,!0,!0),f&&(d[k]=m))),"values"==p&&N&&"values"!==N.name&&(S=!0,A=function(){return N.call(this)}),f&&!b||C[v]===A||u(C,v,A),d[t]=A,p)if(x={values:E("values"),keys:y?A:E("keys"),entries:E("entries")},b)for(_ in x)(g||S||!(_ in C))&&s(C,_,x[_]);else r({target:t,proto:!0,forced:g||S},x);return x}},function(e,t,n){"use strict";var r=n(5);e.exports=!r((function(){function e(){}return e.prototype.constructor=null,Object.getPrototypeOf(new e)!==e.prototype}))},function(e,t,n){"use strict";e.exports="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof DataView},function(e,t,n){"use strict";var r=n(13),o=n(127),i=n(26),a=Math.ceil,c=function(e){return function(t,n,c){var u,s,l=String(i(t)),f=l.length,d=c===undefined?" ":String(c),p=r(n);return p<=f||""==d?l:(u=p-f,(s=o.call(d,a(u/d.length))).length>u&&(s=s.slice(0,u)),e?l+s:s+l)}};e.exports={start:c(!1),end:c(!0)}},function(e,t,n){"use strict";var r=n(38),o=n(26);e.exports="".repeat||function(e){var t=String(o(this)),n="",i=r(e);if(i<0||i==Infinity)throw RangeError("Wrong number of repetitions");for(;i>0;(i>>>=1)&&(t+=t))1&i&&(n+=t);return n}},function(e,t,n){"use strict";e.exports=Math.sign||function(e){return 0==(e=+e)||e!=e?e:e<0?-1:1}},function(e,t,n){"use strict";var r,o,i,a=n(7),c=n(5),u=n(37),s=n(53),l=n(161),f=n(109),d=n(293),p=a.location,h=a.setImmediate,g=a.clearImmediate,v=a.process,m=a.MessageChannel,y=a.Dispatch,b=0,w={},x=function(e){if(w.hasOwnProperty(e)){var t=w[e];delete w[e],t()}},_=function(e){return function(){x(e)}},E=function(e){x(e.data)},k=function(e){a.postMessage(e+"",p.protocol+"//"+p.host)};h&&g||(h=function(e){for(var t=[],n=1;arguments.length>n;)t.push(arguments[n++]);return w[++b]=function(){("function"==typeof e?e:Function(e)).apply(undefined,t)},r(b),b},g=function(e){delete w[e]},"process"==u(v)?r=function(e){v.nextTick(_(e))}:y&&y.now?r=function(e){y.now(_(e))}:m&&!d?(i=(o=new m).port2,o.port1.onmessage=E,r=s(i.postMessage,i,1)):!a.addEventListener||"function"!=typeof postMessage||a.importScripts||c(k)||"file:"===p.protocol?r="onreadystatechange"in f("script")?function(e){l.appendChild(f("script")).onreadystatechange=function(){l.removeChild(this),x(e)}}:function(e){setTimeout(_(e),0)}:(r=k,a.addEventListener("message",E,!1))),e.exports={set:h,clear:g}},function(e,t,n){"use strict";var r=n(29),o=function(e){var t,n;this.promise=new e((function(e,r){if(t!==undefined||n!==undefined)throw TypeError("Bad Promise constructor");t=e,n=r})),this.resolve=r(t),this.reject=r(n)};e.exports.f=function(e){return new o(e)}},function(e,t,n){"use strict";var r=n(38),o=n(26),i=function(e){return function(t,n){var i,a,c=String(o(t)),u=r(n),s=c.length;return u<0||u>=s?e?"":undefined:(i=c.charCodeAt(u))<55296||i>56319||u+1===s||(a=c.charCodeAt(u+1))<56320||a>57343?e?c.charAt(u):i:e?c.slice(u,u+2):a-56320+(i-55296<<10)+65536}};e.exports={codeAt:i(!1),charAt:i(!0)}},function(e,t,n){"use strict";var r=n(94);e.exports=function(e){if(r(e))throw TypeError("The method doesn't accept regular expressions");return e}},function(e,t,n){"use strict";var r=n(15)("match");e.exports=function(e){var t=/./;try{"/./"[e](t)}catch(n){try{return t[r]=!1,"/./"[e](t)}catch(o){}}return!1}},function(e,t,n){"use strict";var r=n(5),o=n(92);e.exports=function(e){return r((function(){return!!o[e]()||"\u200b\x85\u180e"!="\u200b\x85\u180e"[e]()||o[e].name!==e}))}},function(e,t,n){"use strict";var r=n(7),o=n(5),i=n(86),a=n(14).NATIVE_ARRAY_BUFFER_VIEWS,c=r.ArrayBuffer,u=r.Int8Array;e.exports=!a||!o((function(){u(1)}))||!o((function(){new u(-1)}))||!i((function(e){new u,new u(null),new u(1.5),new u(e)}),!0)||o((function(){return 1!==new u(new c(2),1,undefined).length}))},function(e,t,n){"use strict";t.__esModule=!0,t.setupHotKeys=t.releaseHeldKeys=t.releaseHotKey=t.acquireHotKey=void 0;var r=n(78),o=n(57),i=(0,n(24).createLogger)("hotkeys"),a={},c=[r.KEY_ESCAPE,r.KEY_ENTER,r.KEY_SPACE,r.KEY_TAB,r.KEY_CTRL,r.KEY_SHIFT,r.KEY_F5],u={},s=function(e){if(!e.ctrl||e.code!==r.KEY_F5&&e.code!==r.KEY_R){if(!(e.event.defaultPrevented||e.isModifierKey()||c.includes(e.code))){var t,n=16===(t=e.code)?"Shift":17===t?"Ctrl":18===t?"Alt":33===t?"Northeast":34===t?"Southeast":35===t?"Southwest":36===t?"Northwest":37===t?"West":38===t?"North":39===t?"East":40===t?"South":45===t?"Insert":46===t?"Delete":t>=48&&t<=57||t>=65&&t<=90?String.fromCharCode(t):t>=96&&t<=105?"Numpad"+(t-96):t>=112&&t<=123?"F"+(t-111):188===t?",":189===t?"-":190===t?".":void 0;if(n){var o=a[n];if(o)return i.debug("macro",o),Byond.command(o);if(e.isDown()&&!u[n]){u[n]=!0;var s='KeyDown "'+n+'"';return i.debug(s),Byond.command(s)}if(e.isUp()&&u[n]){u[n]=!1;var l='KeyUp "'+n+'"';return i.debug(l),Byond.command(l)}}}}else location.reload()};t.acquireHotKey=function(e){c.push(e)};t.releaseHotKey=function(e){var t=c.indexOf(e);t>=0&&c.splice(t,1)};var l=function(){for(var e=0,t=Object.keys(u);e0&&(n.setState({suppressingFlicker:!0}),clearTimeout(n.flickerTimer),n.flickerTimer=setTimeout((function(){return n.setState({suppressingFlicker:!1})}),e))},n.handleDragStart=function(e){var t=n.props,r=t.value,o=t.dragMatrix;n.state.editing||(document.body.style["pointer-events"]="none",n.ref=e.target,n.setState({dragging:!1,origin:c(e,o),value:r,internalValue:r}),n.timer=setTimeout((function(){n.setState({dragging:!0})}),250),n.dragInterval=setInterval((function(){var t=n.state,r=t.dragging,o=t.value,i=n.props.onDrag;r&&i&&i(e,o)}),n.props.updateRate||400),document.addEventListener("mousemove",n.handleDragMove),document.addEventListener("mouseup",n.handleDragEnd))},n.handleDragMove=function(e){var t=n.props,r=t.minValue,i=t.maxValue,a=t.step,u=t.stepPixelSize,s=t.dragMatrix;n.setState((function(t){var n=Object.assign({},t),l=c(e,s)-n.origin;if(t.dragging){var f=Number.isFinite(r)?r%a:0;n.internalValue=(0,o.clamp)(n.internalValue+l*a/u,r-a,i+a),n.value=(0,o.clamp)(n.internalValue-n.internalValue%a+f,r,i),n.origin=c(e,s)}else Math.abs(l)>4&&(n.dragging=!0);return n}))},n.handleDragEnd=function(e){var t=n.props,r=t.onChange,o=t.onDrag,i=n.state,a=i.dragging,c=i.value,u=i.internalValue;if(document.body.style["pointer-events"]="auto",clearTimeout(n.timer),clearInterval(n.dragInterval),n.setState({dragging:!1,editing:!a,origin:null}),document.removeEventListener("mousemove",n.handleDragMove),document.removeEventListener("mouseup",n.handleDragEnd),a)n.suppressFlicker(),r&&r(e,c),o&&o(e,c);else if(n.inputRef){var s=n.inputRef.current;s.value=u;try{s.focus(),s.select()}catch(l){}}},n}return n=e,(t=i).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n,i.prototype.render=function(){var e=this,t=this.state,n=t.dragging,i=t.editing,c=t.value,u=t.suppressingFlicker,s=this.props,l=s.animated,f=s.value,d=s.unit,p=s.minValue,h=s.maxValue,g=s.format,v=s.onChange,m=s.onDrag,y=s.children,b=s.height,w=s.lineHeight,x=s.fontSize,_=f;(n||u)&&(_=c);var E=function(e){return e+(d?" "+d:"")},k=l&&!n&&!u&&(0,r.createComponentVNode)(2,a.AnimatedNumber,{value:_,format:g,children:E})||E(g?g(_):_),S=(0,r.createVNode)(64,"input","NumberInput__input",null,1,{style:{display:i?undefined:"none",height:b,"line-height":w,"font-size":x},onBlur:function(t){if(i){var n=(0,o.clamp)(parseFloat(t.target.value),p,h);Number.isNaN(n)?e.setState({editing:!1}):(e.setState({editing:!1,value:n}),e.suppressFlicker(),v&&v(t,n),m&&m(t,n))}},onKeyDown:function(t){if(13===t.keyCode){var n=(0,o.clamp)(parseFloat(t.target.value),p,h);return Number.isNaN(n)?void e.setState({editing:!1}):(e.setState({editing:!1,value:n}),e.suppressFlicker(),v&&v(t,n),void(m&&m(t,n)))}27!==t.keyCode||e.setState({editing:!1})}},null,this.inputRef);return y({dragging:n,editing:i,value:f,displayValue:_,displayElement:k,inputElement:S,handleDragStart:this.handleDragStart})},i}(r.Component);t.DraggableControl=u,u.defaultHooks=i.pureComponentHooks,u.defaultProps={minValue:-Infinity,maxValue:+Infinity,step:1,stepPixelSize:1,suppressFlicker:50,dragMatrix:[1,0]}},function(e,t,n){"use strict";t.__esModule=!0,t.FlexItem=t.computeFlexItemProps=t.Flex=t.computeFlexProps=void 0;var r=n(0),o=n(6),i=n(18);function a(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}var c=function(e){var t=e.className,n=e.direction,r=e.wrap,i=e.align,c=e.justify,u=e.inline,s=e.spacing,l=void 0===s?0:s,f=a(e,["className","direction","wrap","align","justify","inline","spacing"]);return Object.assign({className:(0,o.classes)(["Flex",Byond.IS_LTE_IE10&&("column"===n?"Flex--iefix--column":"Flex--iefix"),u&&"Flex--inline",l>0&&"Flex--spacing--"+l,t]),style:Object.assign({},f.style,{"flex-direction":n,"flex-wrap":r,"align-items":i,"justify-content":c})},f)};t.computeFlexProps=c;var u=function(e){return(0,r.normalizeProps)((0,r.createComponentVNode)(2,i.Box,Object.assign({},c(e))))};t.Flex=u,u.defaultHooks=o.pureComponentHooks;var s=function(e){var t=e.className,n=e.style,r=e.grow,c=e.order,u=e.shrink,s=e.basis,l=void 0===s?e.width:s,f=e.align,d=a(e,["className","style","grow","order","shrink","basis","align"]);return Object.assign({className:(0,o.classes)(["Flex__item",Byond.IS_LTE_IE10&&"Flex__item--iefix",Byond.IS_LTE_IE10&&r>0&&"Flex__item--iefix--grow",t]),style:Object.assign({},n,{"flex-grow":r,"flex-shrink":u,"flex-basis":(0,i.unit)(l),order:c,"align-self":f})},d)};t.computeFlexItemProps=s;var l=function(e){return(0,r.normalizeProps)((0,r.createComponentVNode)(2,i.Box,Object.assign({},s(e))))};t.FlexItem=l,l.defaultHooks=o.pureComponentHooks,u.Item=l},function(e,t,n){"use strict";t.__esModule=!0,t.TableCell=t.TableRow=t.Table=void 0;var r=n(0),o=n(6),i=n(18);function a(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}var c=function(e){var t=e.className,n=e.collapsing,c=e.children,u=a(e,["className","collapsing","children"]);return(0,r.normalizeProps)((0,r.createVNode)(1,"table",(0,o.classes)(["Table",n&&"Table--collapsing",t,(0,i.computeBoxClassName)(u)]),(0,r.createVNode)(1,"tbody",null,c,0),2,Object.assign({},(0,i.computeBoxProps)(u))))};t.Table=c,c.defaultHooks=o.pureComponentHooks;var u=function(e){var t=e.className,n=e.header,c=a(e,["className","header"]);return(0,r.normalizeProps)((0,r.createVNode)(1,"tr",(0,o.classes)(["Table__row",n&&"Table__row--header",t,(0,i.computeBoxClassName)(e)]),null,1,Object.assign({},(0,i.computeBoxProps)(c))))};t.TableRow=u,u.defaultHooks=o.pureComponentHooks;var s=function(e){var t=e.className,n=e.collapsing,c=e.header,u=a(e,["className","collapsing","header"]);return(0,r.normalizeProps)((0,r.createVNode)(1,"td",(0,o.classes)(["Table__cell",n&&"Table__cell--collapsing",c&&"Table__cell--header",t,(0,i.computeBoxClassName)(e)]),null,1,Object.assign({},(0,i.computeBoxProps)(u))))};t.TableCell=s,s.defaultHooks=o.pureComponentHooks,c.Row=u,c.Cell=s},function(e,t,n){"use strict";t.__esModule=!0,t.NumberInput=void 0;var r=n(0),o=n(9),i=n(6),a=n(139),c=n(18);var u=function(e){var t,n;function u(t){var n;n=e.call(this,t)||this;var i=t.value;return n.inputRef=(0,r.createRef)(),n.state={value:i,dragging:!1,editing:!1,internalValue:null,origin:null,suppressingFlicker:!1},n.flickerTimer=null,n.suppressFlicker=function(){var e=n.props.suppressFlicker;e>0&&(n.setState({suppressingFlicker:!0}),clearTimeout(n.flickerTimer),n.flickerTimer=setTimeout((function(){return n.setState({suppressingFlicker:!1})}),e))},n.handleDragStart=function(e){var t=n.props.value;n.state.editing||(document.body.style["pointer-events"]="none",n.ref=e.target,n.setState({dragging:!1,origin:e.screenY,value:t,internalValue:t}),n.timer=setTimeout((function(){n.setState({dragging:!0})}),250),n.dragInterval=setInterval((function(){var t=n.state,r=t.dragging,o=t.value,i=n.props.onDrag;r&&i&&i(e,o)}),n.props.updateRate||400),document.addEventListener("mousemove",n.handleDragMove),document.addEventListener("mouseup",n.handleDragEnd))},n.handleDragMove=function(e){var t=n.props,r=t.minValue,i=t.maxValue,a=t.step,c=t.stepPixelSize;n.setState((function(t){var n=Object.assign({},t),u=n.origin-e.screenY;if(t.dragging){var s=Number.isFinite(r)?r%a:0;n.internalValue=(0,o.clamp)(n.internalValue+u*a/c,r-a,i+a),n.value=(0,o.clamp)(n.internalValue-n.internalValue%a+s,r,i),n.origin=e.screenY}else Math.abs(u)>4&&(n.dragging=!0);return n}))},n.handleDragEnd=function(e){var t=n.props,r=t.onChange,o=t.onDrag,i=n.state,a=i.dragging,c=i.value,u=i.internalValue;if(document.body.style["pointer-events"]="auto",clearTimeout(n.timer),clearInterval(n.dragInterval),n.setState({dragging:!1,editing:!a,origin:null}),document.removeEventListener("mousemove",n.handleDragMove),document.removeEventListener("mouseup",n.handleDragEnd),a)n.suppressFlicker(),r&&r(e,c),o&&o(e,c);else if(n.inputRef){var s=n.inputRef.current;s.value=u;try{s.focus(),s.select()}catch(l){}}},n}return n=e,(t=u).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n,u.prototype.render=function(){var e=this,t=this.state,n=t.dragging,u=t.editing,s=t.value,l=t.suppressingFlicker,f=this.props,d=f.className,p=f.fluid,h=f.animated,g=f.value,v=f.unit,m=f.minValue,y=f.maxValue,b=f.height,w=f.width,x=f.lineHeight,_=f.fontSize,E=f.format,k=f.onChange,S=f.onDrag,C=g;(n||l)&&(C=s);var N=function(e){return(0,r.createVNode)(1,"div","NumberInput__content",e+(v?" "+v:""),0,{unselectable:Byond.IS_LTE_IE8})},A=h&&!n&&!l&&(0,r.createComponentVNode)(2,a.AnimatedNumber,{value:C,format:E,children:N})||N(E?E(C):C);return(0,r.createComponentVNode)(2,c.Box,{className:(0,i.classes)(["NumberInput",p&&"NumberInput--fluid",d]),minWidth:w,minHeight:b,lineHeight:x,fontSize:_,onMouseDown:this.handleDragStart,children:[(0,r.createVNode)(1,"div","NumberInput__barContainer",(0,r.createVNode)(1,"div","NumberInput__bar",null,1,{style:{height:(0,o.clamp)((C-m)/(y-m)*100,0,100)+"%"}}),2),A,(0,r.createVNode)(64,"input","NumberInput__input",null,1,{style:{display:u?undefined:"none",height:b,"line-height":x,"font-size":_},onBlur:function(t){if(u){var n=(0,o.clamp)(parseFloat(t.target.value),m,y);Number.isNaN(n)?e.setState({editing:!1}):(e.setState({editing:!1,value:n}),e.suppressFlicker(),k&&k(t,n),S&&S(t,n))}},onKeyDown:function(t){if(13===t.keyCode){var n=(0,o.clamp)(parseFloat(t.target.value),m,y);return Number.isNaN(n)?void e.setState({editing:!1}):(e.setState({editing:!1,value:n}),e.suppressFlicker(),k&&k(t,n),void(S&&S(t,n)))}27!==t.keyCode||e.setState({editing:!1})}},null,this.inputRef)]})},u}(r.Component);t.NumberInput=u,u.defaultHooks=i.pureComponentHooks,u.defaultProps={minValue:-Infinity,maxValue:+Infinity,step:1,stepPixelSize:1,suppressFlicker:50}},function(e,t,n){"use strict";t.__esModule=!0,t.Layout=void 0;var r=n(0),o=n(6),i=n(18),a=n(57);function c(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}var u=function(e){var t=e.className,n=e.theme,a=void 0===n?"nanotrasen":n,u=e.children,s=c(e,["className","theme","children"]);return(0,r.createVNode)(1,"div","theme-"+a,(0,r.normalizeProps)((0,r.createVNode)(1,"div",(0,o.classes)(["Layout",t].concat((0,i.computeBoxClassName)(s))),u,0,Object.assign({},(0,i.computeBoxProps)(s)))),2)};t.Layout=u;var s=function(e){var t=e.className,n=e.scrollable,a=e.children,u=c(e,["className","scrollable","children"]);return(0,r.normalizeProps)((0,r.createVNode)(1,"div",(0,o.classes)(["Layout__content",n&&"Layout__content--scrollable",t].concat((0,i.computeBoxClassName)(u))),a,0,Object.assign({},(0,i.computeBoxProps)(u))))};s.defaultHooks={onComponentDidMount:function(e){return(0,a.addScrollableNode)(e)},onComponentWillUnmount:function(e){return(0,a.removeScrollableNode)(e)}},u.Content=s},,,,,,,,function(e,t,n){"use strict";var r=n(4),o=n(7),i=n(40),a=n(43),c=n(10),u=n(116),s=n(159),l=n(5),f=n(20),d=n(60),p=n(8),h=n(11),g=n(19),v=n(31),m=n(39),y=n(51),b=n(48),w=n(71),x=n(52),_=n(162),E=n(115),k=n(23),S=n(16),C=n(82),N=n(32),A=n(27),T=n(112),O=n(83),I=n(68),M=n(67),L=n(15),V=n(163),R=n(28),P=n(49),B=n(33),D=n(21).forEach,j=O("hidden"),F=L("toPrimitive"),K=B.set,z=B.getterFor("Symbol"),Y=Object.prototype,U=o.Symbol,$=i("JSON","stringify"),H=k.f,W=S.f,q=_.f,G=C.f,X=T("symbols"),Z=T("op-symbols"),Q=T("string-to-symbol-registry"),J=T("symbol-to-string-registry"),ee=T("wks"),te=o.QObject,ne=!te||!te.prototype||!te.prototype.findChild,re=c&&l((function(){return 7!=b(W({},"a",{get:function(){return W(this,"a",{value:7}).a}})).a}))?function(e,t,n){var r=H(Y,t);r&&delete Y[t],W(e,t,n),r&&e!==Y&&W(Y,t,r)}:W,oe=function(e,t){var n=X[e]=b(U.prototype);return K(n,{type:"Symbol",tag:e,description:t}),c||(n.description=t),n},ie=s?function(e){return"symbol"==typeof e}:function(e){return Object(e)instanceof U},ae=function(e,t,n){e===Y&&ae(Z,t,n),h(e);var r=m(t,!0);return h(n),f(X,r)?(n.enumerable?(f(e,j)&&e[j][r]&&(e[j][r]=!1),n=b(n,{enumerable:y(0,!1)})):(f(e,j)||W(e,j,y(1,{})),e[j][r]=!0),re(e,r,n)):W(e,r,n)},ce=function(e,t){h(e);var n=v(t),r=w(n).concat(de(n));return D(r,(function(t){c&&!se.call(n,t)||ae(e,t,n[t])})),e},ue=function(e,t){return t===undefined?b(e):ce(b(e),t)},se=function(e){var t=m(e,!0),n=G.call(this,t);return!(this===Y&&f(X,t)&&!f(Z,t))&&(!(n||!f(this,t)||!f(X,t)||f(this,j)&&this[j][t])||n)},le=function(e,t){var n=v(e),r=m(t,!0);if(n!==Y||!f(X,r)||f(Z,r)){var o=H(n,r);return!o||!f(X,r)||f(n,j)&&n[j][r]||(o.enumerable=!0),o}},fe=function(e){var t=q(v(e)),n=[];return D(t,(function(e){f(X,e)||f(I,e)||n.push(e)})),n},de=function(e){var t=e===Y,n=q(t?Z:v(e)),r=[];return D(n,(function(e){!f(X,e)||t&&!f(Y,e)||r.push(X[e])})),r};(u||(A((U=function(){if(this instanceof U)throw TypeError("Symbol is not a constructor");var e=arguments.length&&arguments[0]!==undefined?String(arguments[0]):undefined,t=M(e),n=function r(e){this===Y&&r.call(Z,e),f(this,j)&&f(this[j],t)&&(this[j][t]=!1),re(this,t,y(1,e))};return c&&ne&&re(Y,t,{configurable:!0,set:n}),oe(t,e)}).prototype,"toString",(function(){return z(this).tag})),A(U,"withoutSetter",(function(e){return oe(M(e),e)})),C.f=se,S.f=ae,k.f=le,x.f=_.f=fe,E.f=de,V.f=function(e){return oe(L(e),e)},c&&(W(U.prototype,"description",{configurable:!0,get:function(){return z(this).description}}),a||A(Y,"propertyIsEnumerable",se,{unsafe:!0}))),r({global:!0,wrap:!0,forced:!u,sham:!u},{Symbol:U}),D(w(ee),(function(e){R(e)})),r({target:"Symbol",stat:!0,forced:!u},{"for":function(e){var t=String(e);if(f(Q,t))return Q[t];var n=U(t);return Q[t]=n,J[n]=t,n},keyFor:function(e){if(!ie(e))throw TypeError(e+" is not a symbol");if(f(J,e))return J[e]},useSetter:function(){ne=!0},useSimple:function(){ne=!1}}),r({target:"Object",stat:!0,forced:!u,sham:!c},{create:ue,defineProperty:ae,defineProperties:ce,getOwnPropertyDescriptor:le}),r({target:"Object",stat:!0,forced:!u},{getOwnPropertyNames:fe,getOwnPropertySymbols:de}),r({target:"Object",stat:!0,forced:l((function(){E.f(1)}))},{getOwnPropertySymbols:function(e){return E.f(g(e))}}),$)&&r({target:"JSON",stat:!0,forced:!u||l((function(){var e=U();return"[null]"!=$([e])||"{}"!=$({a:e})||"{}"!=$(Object(e))}))},{stringify:function(e,t,n){for(var r,o=[e],i=1;arguments.length>i;)o.push(arguments[i++]);if(r=t,(p(t)||e!==undefined)&&!ie(e))return d(t)||(t=function(e,t){if("function"==typeof r&&(t=r.call(this,e,t)),!ie(t))return t}),o[1]=t,$.apply(null,o)}});U.prototype[F]||N(U.prototype,F,U.prototype.valueOf),P(U,"Symbol"),I[j]=!0},function(e,t,n){"use strict";var r=n(10),o=n(5),i=n(109);e.exports=!r&&!o((function(){return 7!=Object.defineProperty(i("div"),"a",{get:function(){return 7}}).a}))},function(e,t,n){"use strict";var r=n(7),o=n(110),i=r["__core-js_shared__"]||o("__core-js_shared__",{});e.exports=i},function(e,t,n){"use strict";var r=n(7),o=n(111),i=r.WeakMap;e.exports="function"==typeof i&&/native code/.test(o(i))},function(e,t,n){"use strict";var r=n(20),o=n(113),i=n(23),a=n(16);e.exports=function(e,t){for(var n=o(t),c=a.f,u=i.f,s=0;su;)r(c,n=t[u++])&&(~i(s,n)||s.push(n));return s}},function(e,t,n){"use strict";var r=n(116);e.exports=r&&!Symbol.sham&&"symbol"==typeof Symbol.iterator},function(e,t,n){"use strict";var r=n(10),o=n(16),i=n(11),a=n(71);e.exports=r?Object.defineProperties:function(e,t){i(e);for(var n,r=a(t),c=r.length,u=0;c>u;)o.f(e,n=r[u++],t[n]);return e}},function(e,t,n){"use strict";var r=n(40);e.exports=r("document","documentElement")},function(e,t,n){"use strict";var r=n(31),o=n(52).f,i={}.toString,a="object"==typeof window&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[];e.exports.f=function(e){return a&&"[object Window]"==i.call(e)?function(e){try{return o(e)}catch(t){return a.slice()}}(e):o(r(e))}},function(e,t,n){"use strict";var r=n(15);t.f=r},function(e,t,n){"use strict";var r=n(4),o=n(10),i=n(7),a=n(20),c=n(8),u=n(16).f,s=n(156),l=i.Symbol;if(o&&"function"==typeof l&&(!("description"in l.prototype)||l().description!==undefined)){var f={},d=function(){var e=arguments.length<1||arguments[0]===undefined?undefined:String(arguments[0]),t=this instanceof d?new l(e):e===undefined?l():l(e);return""===e&&(f[t]=!0),t};s(d,l);var p=d.prototype=l.prototype;p.constructor=d;var h=p.toString,g="Symbol(test)"==String(l("test")),v=/^Symbol\((.*)\)[^)]+$/;u(p,"description",{configurable:!0,get:function(){var e=c(this)?this.valueOf():this,t=h.call(e);if(a(f,e))return"";var n=g?t.slice(7,-1):t.replace(v,"$1");return""===n?undefined:n}}),r({global:!0,forced:!0},{Symbol:d})}},function(e,t,n){"use strict";n(28)("asyncIterator")},function(e,t,n){"use strict";n(28)("hasInstance")},function(e,t,n){"use strict";n(28)("isConcatSpreadable")},function(e,t,n){"use strict";n(28)("iterator")},function(e,t,n){"use strict";n(28)("match")},function(e,t,n){"use strict";n(28)("matchAll")},function(e,t,n){"use strict";n(28)("replace")},function(e,t,n){"use strict";n(28)("search")},function(e,t,n){"use strict";n(28)("species")},function(e,t,n){"use strict";n(28)("split")},function(e,t,n){"use strict";n(28)("toPrimitive")},function(e,t,n){"use strict";n(28)("toStringTag")},function(e,t,n){"use strict";n(28)("unscopables")},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(60),a=n(8),c=n(19),u=n(13),s=n(54),l=n(72),f=n(73),d=n(15),p=n(117),h=d("isConcatSpreadable"),g=p>=51||!o((function(){var e=[];return e[h]=!1,e.concat()[0]!==e})),v=f("concat"),m=function(e){if(!a(e))return!1;var t=e[h];return t!==undefined?!!t:i(e)};r({target:"Array",proto:!0,forced:!g||!v},{concat:function(e){var t,n,r,o,i,a=c(this),f=l(a,0),d=0;for(t=-1,r=arguments.length;t9007199254740991)throw TypeError("Maximum allowed index exceeded");for(n=0;n=9007199254740991)throw TypeError("Maximum allowed index exceeded");s(f,d++,i)}return f.length=d,f}})},function(e,t,n){"use strict";var r=n(4),o=n(180),i=n(50);r({target:"Array",proto:!0},{copyWithin:o}),i("copyWithin")},function(e,t,n){"use strict";var r=n(19),o=n(47),i=n(13),a=Math.min;e.exports=[].copyWithin||function(e,t){var n=r(this),c=i(n.length),u=o(e,c),s=o(t,c),l=arguments.length>2?arguments[2]:undefined,f=a((l===undefined?c:o(l,c))-s,c-u),d=1;for(s0;)s in n?n[u]=n[s]:delete n[u],u+=d,s+=d;return n}},function(e,t,n){"use strict";var r=n(4),o=n(21).every,i=n(44),a=n(30),c=i("every"),u=a("every");r({target:"Array",proto:!0,forced:!c||!u},{every:function(e){return o(this,e,arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(4),o=n(118),i=n(50);r({target:"Array",proto:!0},{fill:o}),i("fill")},function(e,t,n){"use strict";var r=n(4),o=n(21).filter,i=n(73),a=n(30),c=i("filter"),u=a("filter");r({target:"Array",proto:!0,forced:!c||!u},{filter:function(e){return o(this,e,arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(4),o=n(21).find,i=n(50),a=n(30),c=!0,u=a("find");"find"in[]&&Array(1).find((function(){c=!1})),r({target:"Array",proto:!0,forced:c||!u},{find:function(e){return o(this,e,arguments.length>1?arguments[1]:undefined)}}),i("find")},function(e,t,n){"use strict";var r=n(4),o=n(21).findIndex,i=n(50),a=n(30),c=!0,u=a("findIndex");"findIndex"in[]&&Array(1).findIndex((function(){c=!1})),r({target:"Array",proto:!0,forced:c||!u},{findIndex:function(e){return o(this,e,arguments.length>1?arguments[1]:undefined)}}),i("findIndex")},function(e,t,n){"use strict";var r=n(4),o=n(187),i=n(19),a=n(13),c=n(38),u=n(72);r({target:"Array",proto:!0},{flat:function(){var e=arguments.length?arguments[0]:undefined,t=i(this),n=a(t.length),r=u(t,0);return r.length=o(r,t,t,n,0,e===undefined?1:c(e)),r}})},function(e,t,n){"use strict";var r=n(60),o=n(13),i=n(53);e.exports=function a(e,t,n,c,u,s,l,f){for(var d,p=u,h=0,g=!!l&&i(l,f,3);h0&&r(d))p=a(e,t,d,o(d.length),p,s-1)-1;else{if(p>=9007199254740991)throw TypeError("Exceed the acceptable array length");e[p]=d}p++}h++}return p}},function(e,t,n){"use strict";var r=n(4),o=n(187),i=n(19),a=n(13),c=n(29),u=n(72);r({target:"Array",proto:!0},{flatMap:function(e){var t,n=i(this),r=a(n.length);return c(e),(t=u(n,0)).length=o(t,n,n,r,0,1,e,arguments.length>1?arguments[1]:undefined),t}})},function(e,t,n){"use strict";var r=n(4),o=n(434);r({target:"Array",proto:!0,forced:[].forEach!=o},{forEach:o})},function(e,t,n){"use strict";var r=n(4),o=n(435);r({target:"Array",stat:!0,forced:!n(86)((function(e){Array.from(e)}))},{from:o})},function(e,t,n){"use strict";var r=n(11);e.exports=function(e,t,n,o){try{return o?t(r(n)[0],n[1]):t(n)}catch(a){var i=e["return"];throw i!==undefined&&r(i.call(e)),a}}},function(e,t,n){"use strict";var r=n(4),o=n(69).includes,i=n(50);r({target:"Array",proto:!0,forced:!n(30)("indexOf",{ACCESSORS:!0,1:0})},{includes:function(e){return o(this,e,arguments.length>1?arguments[1]:undefined)}}),i("includes")},function(e,t,n){"use strict";var r=n(4),o=n(69).indexOf,i=n(44),a=n(30),c=[].indexOf,u=!!c&&1/[1].indexOf(1,-0)<0,s=i("indexOf"),l=a("indexOf",{ACCESSORS:!0,1:0});r({target:"Array",proto:!0,forced:u||!s||!l},{indexOf:function(e){return u?c.apply(this,arguments)||0:o(this,e,arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";n(4)({target:"Array",stat:!0},{isArray:n(60)})},function(e,t,n){"use strict";var r=n(196).IteratorPrototype,o=n(48),i=n(51),a=n(49),c=n(74),u=function(){return this};e.exports=function(e,t,n){var s=t+" Iterator";return e.prototype=o(r,{next:i(1,n)}),a(e,s,!1,!0),c[s]=u,e}},function(e,t,n){"use strict";var r,o,i,a=n(41),c=n(32),u=n(20),s=n(15),l=n(43),f=s("iterator"),d=!1;[].keys&&("next"in(i=[].keys())?(o=a(a(i)))!==Object.prototype&&(r=o):d=!0),r==undefined&&(r={}),l||u(r,f)||c(r,f,(function(){return this})),e.exports={IteratorPrototype:r,BUGGY_SAFARI_ITERATORS:d}},function(e,t,n){"use strict";var r=n(8);e.exports=function(e){if(!r(e)&&null!==e)throw TypeError("Can't set "+String(e)+" as a prototype");return e}},function(e,t,n){"use strict";var r=n(4),o=n(66),i=n(31),a=n(44),c=[].join,u=o!=Object,s=a("join",",");r({target:"Array",proto:!0,forced:u||!s},{join:function(e){return c.call(i(this),e===undefined?",":e)}})},function(e,t,n){"use strict";var r=n(4),o=n(200);r({target:"Array",proto:!0,forced:o!==[].lastIndexOf},{lastIndexOf:o})},function(e,t,n){"use strict";var r=n(31),o=n(38),i=n(13),a=n(44),c=n(30),u=Math.min,s=[].lastIndexOf,l=!!s&&1/[1].lastIndexOf(1,-0)<0,f=a("lastIndexOf"),d=c("indexOf",{ACCESSORS:!0,1:0}),p=l||!f||!d;e.exports=p?function(e){if(l)return s.apply(this,arguments)||0;var t=r(this),n=i(t.length),a=n-1;for(arguments.length>1&&(a=u(a,o(arguments[1]))),a<0&&(a=n+a);a>=0;a--)if(a in t&&t[a]===e)return a||0;return-1}:s},function(e,t,n){"use strict";var r=n(4),o=n(21).map,i=n(73),a=n(30),c=i("map"),u=a("map");r({target:"Array",proto:!0,forced:!c||!u},{map:function(e){return o(this,e,arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(54);r({target:"Array",stat:!0,forced:o((function(){function e(){}return!(Array.of.call(e)instanceof e)}))},{of:function(){for(var e=0,t=arguments.length,n=new("function"==typeof this?this:Array)(t);t>e;)i(n,e,arguments[e++]);return n.length=t,n}})},function(e,t,n){"use strict";var r=n(4),o=n(87).left,i=n(44),a=n(30),c=i("reduce"),u=a("reduce",{1:0});r({target:"Array",proto:!0,forced:!c||!u},{reduce:function(e){return o(this,e,arguments.length,arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(4),o=n(87).right,i=n(44),a=n(30),c=i("reduceRight"),u=a("reduce",{1:0});r({target:"Array",proto:!0,forced:!c||!u},{reduceRight:function(e){return o(this,e,arguments.length,arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(4),o=n(8),i=n(60),a=n(47),c=n(13),u=n(31),s=n(54),l=n(15),f=n(73),d=n(30),p=f("slice"),h=d("slice",{ACCESSORS:!0,0:0,1:2}),g=l("species"),v=[].slice,m=Math.max;r({target:"Array",proto:!0,forced:!p||!h},{slice:function(e,t){var n,r,l,f=u(this),d=c(f.length),p=a(e,d),h=a(t===undefined?d:t,d);if(i(f)&&("function"!=typeof(n=f.constructor)||n!==Array&&!i(n.prototype)?o(n)&&null===(n=n[g])&&(n=undefined):n=undefined,n===Array||n===undefined))return v.call(f,p,h);for(r=new(n===undefined?Array:n)(m(h-p,0)),l=0;p1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(4),o=n(29),i=n(19),a=n(5),c=n(44),u=[],s=u.sort,l=a((function(){u.sort(undefined)})),f=a((function(){u.sort(null)})),d=c("sort");r({target:"Array",proto:!0,forced:l||!f||!d},{sort:function(e){return e===undefined?s.call(i(this)):s.call(i(this),o(e))}})},function(e,t,n){"use strict";n(61)("Array")},function(e,t,n){"use strict";var r=n(4),o=n(47),i=n(38),a=n(13),c=n(19),u=n(72),s=n(54),l=n(73),f=n(30),d=l("splice"),p=f("splice",{ACCESSORS:!0,0:0,1:2}),h=Math.max,g=Math.min;r({target:"Array",proto:!0,forced:!d||!p},{splice:function(e,t){var n,r,l,f,d,p,v=c(this),m=a(v.length),y=o(e,m),b=arguments.length;if(0===b?n=r=0:1===b?(n=0,r=m-y):(n=b-2,r=g(h(i(t),0),m-y)),m+n-r>9007199254740991)throw TypeError("Maximum allowed length exceeded");for(l=u(v,r),f=0;fm-r+n;f--)delete v[f-1]}else if(n>r)for(f=m-r;f>y;f--)p=f+n-1,(d=f+r-1)in v?v[p]=v[d]:delete v[p];for(f=0;f1?arguments[1]:undefined,3);t=t?t.next:n.first;)for(r(t.value,t.key,this);t&&t.removed;)t=t.previous},has:function(e){return!!m(this,e)}}),i(l.prototype,n?{get:function(e){var t=m(this,e);return t&&t.value},set:function(e,t){return v(this,0===e?0:e,t)}}:{add:function(e){return v(this,e=0===e?0:e,e)}}),f&&r(l.prototype,"size",{get:function(){return p(this).size}}),l},setStrong:function(e,t,n){var r=t+" Iterator",o=g(t),i=g(r);s(e,t,(function(e,t){h(this,{type:r,target:e,state:o(e),kind:t,last:undefined})}),(function(){for(var e=i(this),t=e.kind,n=e.last;n&&n.removed;)n=n.previous;return e.target&&(e.last=n=n?n.next:e.state.first)?"keys"==t?{value:n.key,done:!1}:"values"==t?{value:n.value,done:!1}:{value:[n.key,n.value],done:!1}:(e.target=undefined,{value:undefined,done:!0})}),n?"entries":"values",!n,!0),l(t)}}},function(e,t,n){"use strict";var r=n(4),o=n(232),i=Math.acosh,a=Math.log,c=Math.sqrt,u=Math.LN2;r({target:"Math",stat:!0,forced:!i||710!=Math.floor(i(Number.MAX_VALUE))||i(Infinity)!=Infinity},{acosh:function(e){return(e=+e)<1?NaN:e>94906265.62425156?a(e)+u:o(e-1+c(e-1)*c(e+1))}})},function(e,t,n){"use strict";var r=Math.log;e.exports=Math.log1p||function(e){return(e=+e)>-1e-8&&e<1e-8?e-e*e/2:r(1+e)}},function(e,t,n){"use strict";var r=n(4),o=Math.asinh,i=Math.log,a=Math.sqrt;r({target:"Math",stat:!0,forced:!(o&&1/o(0)>0)},{asinh:function c(e){return isFinite(e=+e)&&0!=e?e<0?-c(-e):i(e+a(e*e+1)):e}})},function(e,t,n){"use strict";var r=n(4),o=Math.atanh,i=Math.log;r({target:"Math",stat:!0,forced:!(o&&1/o(-0)<0)},{atanh:function(e){return 0==(e=+e)?e:i((1+e)/(1-e))/2}})},function(e,t,n){"use strict";var r=n(4),o=n(128),i=Math.abs,a=Math.pow;r({target:"Math",stat:!0},{cbrt:function(e){return o(e=+e)*a(i(e),1/3)}})},function(e,t,n){"use strict";var r=n(4),o=Math.floor,i=Math.log,a=Math.LOG2E;r({target:"Math",stat:!0},{clz32:function(e){return(e>>>=0)?31-o(i(e+.5)*a):32}})},function(e,t,n){"use strict";var r=n(4),o=n(91),i=Math.cosh,a=Math.abs,c=Math.E;r({target:"Math",stat:!0,forced:!i||i(710)===Infinity},{cosh:function(e){var t=o(a(e)-1)+1;return(t+1/(t*c*c))*(c/2)}})},function(e,t,n){"use strict";var r=n(4),o=n(91);r({target:"Math",stat:!0,forced:o!=Math.expm1},{expm1:o})},function(e,t,n){"use strict";n(4)({target:"Math",stat:!0},{fround:n(439)})},function(e,t,n){"use strict";var r=n(4),o=Math.hypot,i=Math.abs,a=Math.sqrt;r({target:"Math",stat:!0,forced:!!o&&o(Infinity,NaN)!==Infinity},{hypot:function(e,t){for(var n,r,o=0,c=0,u=arguments.length,s=0;c0?(r=n/s)*r:n;return s===Infinity?Infinity:s*a(o)}})},function(e,t,n){"use strict";var r=n(4),o=n(5),i=Math.imul;r({target:"Math",stat:!0,forced:o((function(){return-5!=i(4294967295,5)||2!=i.length}))},{imul:function(e,t){var n=+e,r=+t,o=65535&n,i=65535&r;return 0|o*i+((65535&n>>>16)*i+o*(65535&r>>>16)<<16>>>0)}})},function(e,t,n){"use strict";var r=n(4),o=Math.log,i=Math.LOG10E;r({target:"Math",stat:!0},{log10:function(e){return o(e)*i}})},function(e,t,n){"use strict";n(4)({target:"Math",stat:!0},{log1p:n(232)})},function(e,t,n){"use strict";var r=n(4),o=Math.log,i=Math.LN2;r({target:"Math",stat:!0},{log2:function(e){return o(e)/i}})},function(e,t,n){"use strict";n(4)({target:"Math",stat:!0},{sign:n(128)})},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(91),a=Math.abs,c=Math.exp,u=Math.E;r({target:"Math",stat:!0,forced:o((function(){return-2e-17!=Math.sinh(-2e-17)}))},{sinh:function(e){return a(e=+e)<1?(i(e)-i(-e))/2:(c(e-1)-c(-e-1))*(u/2)}})},function(e,t,n){"use strict";var r=n(4),o=n(91),i=Math.exp;r({target:"Math",stat:!0},{tanh:function(e){var t=o(e=+e),n=o(-e);return t==Infinity?1:n==Infinity?-1:(t-n)/(i(e)+i(-e))}})},function(e,t,n){"use strict";n(49)(Math,"Math",!0)},function(e,t,n){"use strict";var r=n(4),o=Math.ceil,i=Math.floor;r({target:"Math",stat:!0},{trunc:function(e){return(e>0?i:o)(e)}})},function(e,t,n){"use strict";var r=n(10),o=n(7),i=n(70),a=n(27),c=n(20),u=n(37),s=n(90),l=n(39),f=n(5),d=n(48),p=n(52).f,h=n(23).f,g=n(16).f,v=n(64).trim,m=o.Number,y=m.prototype,b="Number"==u(d(y)),w=function(e){var t,n,r,o,i,a,c,u,s=l(e,!1);if("string"==typeof s&&s.length>2)if(43===(t=(s=v(s)).charCodeAt(0))||45===t){if(88===(n=s.charCodeAt(2))||120===n)return NaN}else if(48===t){switch(s.charCodeAt(1)){case 66:case 98:r=2,o=49;break;case 79:case 111:r=8,o=55;break;default:return+s}for(a=(i=s.slice(2)).length,c=0;co)return NaN;return parseInt(i,r)}return+s};if(i("Number",!m(" 0o1")||!m("0b1")||m("+0x1"))){for(var x,_=function(e){var t=arguments.length<1?0:e,n=this;return n instanceof _&&(b?f((function(){y.valueOf.call(n)})):"Number"!=u(n))?s(new m(w(t)),n,_):w(t)},E=r?p(m):"MAX_VALUE,MIN_VALUE,NaN,NEGATIVE_INFINITY,POSITIVE_INFINITY,EPSILON,isFinite,isInteger,isNaN,isSafeInteger,MAX_SAFE_INTEGER,MIN_SAFE_INTEGER,parseFloat,parseInt,isInteger".split(","),k=0;E.length>k;k++)c(m,x=E[k])&&!c(_,x)&&g(_,x,h(m,x));_.prototype=y,y.constructor=_,a(o,"Number",_)}},function(e,t,n){"use strict";n(4)({target:"Number",stat:!0},{EPSILON:Math.pow(2,-52)})},function(e,t,n){"use strict";n(4)({target:"Number",stat:!0},{isFinite:n(440)})},function(e,t,n){"use strict";n(4)({target:"Number",stat:!0},{isInteger:n(254)})},function(e,t,n){"use strict";var r=n(8),o=Math.floor;e.exports=function(e){return!r(e)&&isFinite(e)&&o(e)===e}},function(e,t,n){"use strict";n(4)({target:"Number",stat:!0},{isNaN:function(e){return e!=e}})},function(e,t,n){"use strict";var r=n(4),o=n(254),i=Math.abs;r({target:"Number",stat:!0},{isSafeInteger:function(e){return o(e)&&i(e)<=9007199254740991}})},function(e,t,n){"use strict";n(4)({target:"Number",stat:!0},{MAX_SAFE_INTEGER:9007199254740991})},function(e,t,n){"use strict";n(4)({target:"Number",stat:!0},{MIN_SAFE_INTEGER:-9007199254740991})},function(e,t,n){"use strict";var r=n(4),o=n(441);r({target:"Number",stat:!0,forced:Number.parseFloat!=o},{parseFloat:o})},function(e,t,n){"use strict";var r=n(4),o=n(261);r({target:"Number",stat:!0,forced:Number.parseInt!=o},{parseInt:o})},function(e,t,n){"use strict";var r=n(7),o=n(64).trim,i=n(92),a=r.parseInt,c=/^[+-]?0[Xx]/,u=8!==a(i+"08")||22!==a(i+"0x16");e.exports=u?function(e,t){var n=o(String(e));return a(n,t>>>0||(c.test(n)?16:10))}:a},function(e,t,n){"use strict";var r=n(4),o=n(38),i=n(442),a=n(127),c=n(5),u=1..toFixed,s=Math.floor,l=function f(e,t,n){return 0===t?n:t%2==1?f(e,t-1,n*e):f(e*e,t/2,n)};r({target:"Number",proto:!0,forced:u&&("0.000"!==8e-5.toFixed(3)||"1"!==.9.toFixed(0)||"1.25"!==1.255.toFixed(2)||"1000000000000000128"!==(0xde0b6b3a7640080).toFixed(0))||!c((function(){u.call({})}))},{toFixed:function(e){var t,n,r,c,u=i(this),f=o(e),d=[0,0,0,0,0,0],p="",h="0",g=function(e,t){for(var n=-1,r=t;++n<6;)r+=e*d[n],d[n]=r%1e7,r=s(r/1e7)},v=function(e){for(var t=6,n=0;--t>=0;)n+=d[t],d[t]=s(n/e),n=n%e*1e7},m=function(){for(var e=6,t="";--e>=0;)if(""!==t||0===e||0!==d[e]){var n=String(d[e]);t=""===t?n:t+a.call("0",7-n.length)+n}return t};if(f<0||f>20)throw RangeError("Incorrect fraction digits");if(u!=u)return"NaN";if(u<=-1e21||u>=1e21)return String(u);if(u<0&&(p="-",u=-u),u>1e-21)if(n=(t=function(e){for(var t=0,n=e;n>=4096;)t+=12,n/=4096;for(;n>=2;)t+=1,n/=2;return t}(u*l(2,69,1))-69)<0?u*l(2,-t,1):u/l(2,t,1),n*=4503599627370496,(t=52-t)>0){for(g(0,n),r=f;r>=7;)g(1e7,0),r-=7;for(g(l(10,r,1),0),r=t-1;r>=23;)v(1<<23),r-=23;v(1<0?p+((c=h.length)<=f?"0."+a.call("0",f-c)+h:h.slice(0,c-f)+"."+h.slice(c-f)):p+h}})},function(e,t,n){"use strict";var r=n(4),o=n(443);r({target:"Object",stat:!0,forced:Object.assign!==o},{assign:o})},function(e,t,n){"use strict";n(4)({target:"Object",stat:!0,sham:!n(10)},{create:n(48)})},function(e,t,n){"use strict";var r=n(4),o=n(10),i=n(93),a=n(19),c=n(29),u=n(16);o&&r({target:"Object",proto:!0,forced:i},{__defineGetter__:function(e,t){u.f(a(this),e,{get:c(t),enumerable:!0,configurable:!0})}})},function(e,t,n){"use strict";var r=n(4),o=n(10);r({target:"Object",stat:!0,forced:!o,sham:!o},{defineProperties:n(160)})},function(e,t,n){"use strict";var r=n(4),o=n(10);r({target:"Object",stat:!0,forced:!o,sham:!o},{defineProperty:n(16).f})},function(e,t,n){"use strict";var r=n(4),o=n(10),i=n(93),a=n(19),c=n(29),u=n(16);o&&r({target:"Object",proto:!0,forced:i},{__defineSetter__:function(e,t){u.f(a(this),e,{set:c(t),enumerable:!0,configurable:!0})}})},function(e,t,n){"use strict";var r=n(4),o=n(270).entries;r({target:"Object",stat:!0},{entries:function(e){return o(e)}})},function(e,t,n){"use strict";var r=n(10),o=n(71),i=n(31),a=n(82).f,c=function(e){return function(t){for(var n,c=i(t),u=o(c),s=u.length,l=0,f=[];s>l;)n=u[l++],r&&!a.call(c,n)||f.push(e?[n,c[n]]:c[n]);return f}};e.exports={entries:c(!0),values:c(!1)}},function(e,t,n){"use strict";var r=n(4),o=n(76),i=n(5),a=n(8),c=n(56).onFreeze,u=Object.freeze;r({target:"Object",stat:!0,forced:i((function(){u(1)})),sham:!o},{freeze:function(e){return u&&a(e)?u(c(e)):e}})},function(e,t,n){"use strict";var r=n(4),o=n(63),i=n(54);r({target:"Object",stat:!0},{fromEntries:function(e){var t={};return o(e,(function(e,n){i(t,e,n)}),undefined,!0),t}})},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(31),a=n(23).f,c=n(10),u=o((function(){a(1)}));r({target:"Object",stat:!0,forced:!c||u,sham:!c},{getOwnPropertyDescriptor:function(e,t){return a(i(e),t)}})},function(e,t,n){"use strict";var r=n(4),o=n(10),i=n(113),a=n(31),c=n(23),u=n(54);r({target:"Object",stat:!0,sham:!o},{getOwnPropertyDescriptors:function(e){for(var t,n,r=a(e),o=c.f,s=i(r),l={},f=0;s.length>f;)(n=o(r,t=s[f++]))!==undefined&&u(l,t,n);return l}})},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(162).f;r({target:"Object",stat:!0,forced:o((function(){return!Object.getOwnPropertyNames(1)}))},{getOwnPropertyNames:i})},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(19),a=n(41),c=n(124);r({target:"Object",stat:!0,forced:o((function(){a(1)})),sham:!c},{getPrototypeOf:function(e){return a(i(e))}})},function(e,t,n){"use strict";n(4)({target:"Object",stat:!0},{is:n(278)})},function(e,t,n){"use strict";e.exports=Object.is||function(e,t){return e===t?0!==e||1/e==1/t:e!=e&&t!=t}},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(8),a=Object.isExtensible;r({target:"Object",stat:!0,forced:o((function(){a(1)}))},{isExtensible:function(e){return!!i(e)&&(!a||a(e))}})},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(8),a=Object.isFrozen;r({target:"Object",stat:!0,forced:o((function(){a(1)}))},{isFrozen:function(e){return!i(e)||!!a&&a(e)}})},function(e,t,n){"use strict";var r=n(4),o=n(5),i=n(8),a=Object.isSealed;r({target:"Object",stat:!0,forced:o((function(){a(1)}))},{isSealed:function(e){return!i(e)||!!a&&a(e)}})},function(e,t,n){"use strict";var r=n(4),o=n(19),i=n(71);r({target:"Object",stat:!0,forced:n(5)((function(){i(1)}))},{keys:function(e){return i(o(e))}})},function(e,t,n){"use strict";var r=n(4),o=n(10),i=n(93),a=n(19),c=n(39),u=n(41),s=n(23).f;o&&r({target:"Object",proto:!0,forced:i},{__lookupGetter__:function(e){var t,n=a(this),r=c(e,!0);do{if(t=s(n,r))return t.get}while(n=u(n))}})},function(e,t,n){"use strict";var r=n(4),o=n(10),i=n(93),a=n(19),c=n(39),u=n(41),s=n(23).f;o&&r({target:"Object",proto:!0,forced:i},{__lookupSetter__:function(e){var t,n=a(this),r=c(e,!0);do{if(t=s(n,r))return t.set}while(n=u(n))}})},function(e,t,n){"use strict";var r=n(4),o=n(8),i=n(56).onFreeze,a=n(76),c=n(5),u=Object.preventExtensions;r({target:"Object",stat:!0,forced:c((function(){u(1)})),sham:!a},{preventExtensions:function(e){return u&&o(e)?u(i(e)):e}})},function(e,t,n){"use strict";var r=n(4),o=n(8),i=n(56).onFreeze,a=n(76),c=n(5),u=Object.seal;r({target:"Object",stat:!0,forced:c((function(){u(1)})),sham:!a},{seal:function(e){return u&&o(e)?u(i(e)):e}})},function(e,t,n){"use strict";n(4)({target:"Object",stat:!0},{setPrototypeOf:n(55)})},function(e,t,n){"use strict";var r=n(121),o=n(27),i=n(444);r||o(Object.prototype,"toString",i,{unsafe:!0})},function(e,t,n){"use strict";var r=n(4),o=n(270).values;r({target:"Object",stat:!0},{values:function(e){return o(e)}})},function(e,t,n){"use strict";var r=n(4),o=n(261);r({global:!0,forced:parseInt!=o},{parseInt:o})},function(e,t,n){"use strict";var r,o,i,a,c=n(4),u=n(43),s=n(7),l=n(40),f=n(292),d=n(27),p=n(75),h=n(49),g=n(61),v=n(8),m=n(29),y=n(62),b=n(37),w=n(111),x=n(63),_=n(86),E=n(45),k=n(129).set,S=n(294),C=n(295),N=n(445),A=n(130),T=n(296),O=n(33),I=n(70),M=n(15),L=n(117),V=M("species"),R="Promise",P=O.get,B=O.set,D=O.getterFor(R),j=f,F=s.TypeError,K=s.document,z=s.process,Y=l("fetch"),U=A.f,$=U,H="process"==b(z),W=!!(K&&K.createEvent&&s.dispatchEvent),q=I(R,(function(){if(!(w(j)!==String(j))){if(66===L)return!0;if(!H&&"function"!=typeof PromiseRejectionEvent)return!0}if(u&&!j.prototype["finally"])return!0;if(L>=51&&/native code/.test(j))return!1;var e=j.resolve(1),t=function(e){e((function(){}),(function(){}))};return(e.constructor={})[V]=t,!(e.then((function(){}))instanceof t)})),G=q||!_((function(e){j.all(e)["catch"]((function(){}))})),X=function(e){var t;return!(!v(e)||"function"!=typeof(t=e.then))&&t},Z=function(e,t,n){if(!t.notified){t.notified=!0;var r=t.reactions;S((function(){for(var o=t.value,i=1==t.state,a=0;r.length>a;){var c,u,s,l=r[a++],f=i?l.ok:l.fail,d=l.resolve,p=l.reject,h=l.domain;try{f?(i||(2===t.rejection&&te(e,t),t.rejection=1),!0===f?c=o:(h&&h.enter(),c=f(o),h&&(h.exit(),s=!0)),c===l.promise?p(F("Promise-chain cycle")):(u=X(c))?u.call(c,d,p):d(c)):p(o)}catch(g){h&&!s&&h.exit(),p(g)}}t.reactions=[],t.notified=!1,n&&!t.rejection&&J(e,t)}))}},Q=function(e,t,n){var r,o;W?((r=K.createEvent("Event")).promise=t,r.reason=n,r.initEvent(e,!1,!0),s.dispatchEvent(r)):r={promise:t,reason:n},(o=s["on"+e])?o(r):"unhandledrejection"===e&&N("Unhandled promise rejection",n)},J=function(e,t){k.call(s,(function(){var n,r=t.value;if(ee(t)&&(n=T((function(){H?z.emit("unhandledRejection",r,e):Q("unhandledrejection",e,r)})),t.rejection=H||ee(t)?2:1,n.error))throw n.value}))},ee=function(e){return 1!==e.rejection&&!e.parent},te=function(e,t){k.call(s,(function(){H?z.emit("rejectionHandled",e):Q("rejectionhandled",e,t.value)}))},ne=function(e,t,n,r){return function(o){e(t,n,o,r)}},re=function(e,t,n,r){t.done||(t.done=!0,r&&(t=r),t.value=n,t.state=2,Z(e,t,!0))},oe=function ie(e,t,n,r){if(!t.done){t.done=!0,r&&(t=r);try{if(e===n)throw F("Promise can't be resolved itself");var o=X(n);o?S((function(){var r={done:!1};try{o.call(n,ne(ie,e,r,t),ne(re,e,r,t))}catch(i){re(e,r,i,t)}})):(t.value=n,t.state=1,Z(e,t,!1))}catch(i){re(e,{done:!1},i,t)}}};q&&(j=function(e){y(this,j,R),m(e),r.call(this);var t=P(this);try{e(ne(oe,this,t),ne(re,this,t))}catch(n){re(this,t,n)}},(r=function(e){B(this,{type:R,done:!1,notified:!1,parent:!1,reactions:[],rejection:!1,state:0,value:undefined})}).prototype=p(j.prototype,{then:function(e,t){var n=D(this),r=U(E(this,j));return r.ok="function"!=typeof e||e,r.fail="function"==typeof t&&t,r.domain=H?z.domain:undefined,n.parent=!0,n.reactions.push(r),0!=n.state&&Z(this,n,!1),r.promise},"catch":function(e){return this.then(undefined,e)}}),o=function(){var e=new r,t=P(e);this.promise=e,this.resolve=ne(oe,e,t),this.reject=ne(re,e,t)},A.f=U=function(e){return e===j||e===i?new o(e):$(e)},u||"function"!=typeof f||(a=f.prototype.then,d(f.prototype,"then",(function(e,t){var n=this;return new j((function(e,t){a.call(n,e,t)})).then(e,t)}),{unsafe:!0}),"function"==typeof Y&&c({global:!0,enumerable:!0,forced:!0},{fetch:function(e){return C(j,Y.apply(s,arguments))}}))),c({global:!0,wrap:!0,forced:q},{Promise:j}),h(j,R,!1,!0),g(R),i=l(R),c({target:R,stat:!0,forced:q},{reject:function(e){var t=U(this);return t.reject.call(undefined,e),t.promise}}),c({target:R,stat:!0,forced:u||q},{resolve:function(e){return C(u&&this===i?j:this,e)}}),c({target:R,stat:!0,forced:G},{all:function(e){var t=this,n=U(t),r=n.resolve,o=n.reject,i=T((function(){var n=m(t.resolve),i=[],a=0,c=1;x(e,(function(e){var u=a++,s=!1;i.push(undefined),c++,n.call(t,e).then((function(e){s||(s=!0,i[u]=e,--c||r(i))}),o)})),--c||r(i)}));return i.error&&o(i.value),n.promise},race:function(e){var t=this,n=U(t),r=n.reject,o=T((function(){var o=m(t.resolve);x(e,(function(e){o.call(t,e).then(n.resolve,r)}))}));return o.error&&r(o.value),n.promise}})},function(e,t,n){"use strict";var r=n(7);e.exports=r.Promise},function(e,t,n){"use strict";var r=n(84);e.exports=/(iphone|ipod|ipad).*applewebkit/i.test(r)},function(e,t,n){"use strict";var r,o,i,a,c,u,s,l,f=n(7),d=n(23).f,p=n(37),h=n(129).set,g=n(293),v=f.MutationObserver||f.WebKitMutationObserver,m=f.process,y=f.Promise,b="process"==p(m),w=d(f,"queueMicrotask"),x=w&&w.value;x||(r=function(){var e,t;for(b&&(e=m.domain)&&e.exit();o;){t=o.fn,o=o.next;try{t()}catch(n){throw o?a():i=undefined,n}}i=undefined,e&&e.enter()},b?a=function(){m.nextTick(r)}:v&&!g?(c=!0,u=document.createTextNode(""),new v(r).observe(u,{characterData:!0}),a=function(){u.data=c=!c}):y&&y.resolve?(s=y.resolve(undefined),l=s.then,a=function(){l.call(s,r)}):a=function(){h.call(f,r)}),e.exports=x||function(e){var t={fn:e,next:undefined};i&&(i.next=t),o||(o=t,a()),i=t}},function(e,t,n){"use strict";var r=n(11),o=n(8),i=n(130);e.exports=function(e,t){if(r(e),o(t)&&t.constructor===e)return t;var n=i.f(e);return(0,n.resolve)(t),n.promise}},function(e,t,n){"use strict";e.exports=function(e){try{return{error:!1,value:e()}}catch(t){return{error:!0,value:t}}}},function(e,t,n){"use strict";var r=n(4),o=n(29),i=n(130),a=n(296),c=n(63);r({target:"Promise",stat:!0},{allSettled:function(e){var t=this,n=i.f(t),r=n.resolve,u=n.reject,s=a((function(){var n=o(t.resolve),i=[],a=0,u=1;c(e,(function(e){var o=a++,c=!1;i.push(undefined),u++,n.call(t,e).then((function(e){c||(c=!0,i[o]={status:"fulfilled",value:e},--u||r(i))}),(function(e){c||(c=!0,i[o]={status:"rejected",reason:e},--u||r(i))}))})),--u||r(i)}));return s.error&&u(s.value),n.promise}})},function(e,t,n){"use strict";var r=n(4),o=n(43),i=n(292),a=n(5),c=n(40),u=n(45),s=n(295),l=n(27);r({target:"Promise",proto:!0,real:!0,forced:!!i&&a((function(){i.prototype["finally"].call({then:function(){}},(function(){}))}))},{"finally":function(e){var t=u(this,c("Promise")),n="function"==typeof e;return this.then(n?function(n){return s(t,e()).then((function(){return n}))}:e,n?function(n){return s(t,e()).then((function(){throw n}))}:e)}}),o||"function"!=typeof i||i.prototype["finally"]||l(i.prototype,"finally",c("Promise").prototype["finally"])},function(e,t,n){"use strict";var r=n(4),o=n(40),i=n(29),a=n(11),c=n(5),u=o("Reflect","apply"),s=Function.apply;r({target:"Reflect",stat:!0,forced:!c((function(){u((function(){}))}))},{apply:function(e,t,n){return i(e),a(n),u?u(e,t,n):s.call(e,t,n)}})},function(e,t,n){"use strict";var r=n(4),o=n(40),i=n(29),a=n(11),c=n(8),u=n(48),s=n(223),l=n(5),f=o("Reflect","construct"),d=l((function(){function e(){}return!(f((function(){}),[],e)instanceof e)})),p=!l((function(){f((function(){}))})),h=d||p;r({target:"Reflect",stat:!0,forced:h,sham:h},{construct:function(e,t){i(e),a(t);var n=arguments.length<3?e:i(arguments[2]);if(p&&!d)return f(e,t,n);if(e==n){switch(t.length){case 0:return new e;case 1:return new e(t[0]);case 2:return new e(t[0],t[1]);case 3:return new e(t[0],t[1],t[2]);case 4:return new e(t[0],t[1],t[2],t[3])}var r=[null];return r.push.apply(r,t),new(s.apply(e,r))}var o=n.prototype,l=u(c(o)?o:Object.prototype),h=Function.apply.call(e,l,t);return c(h)?h:l}})},function(e,t,n){"use strict";var r=n(4),o=n(10),i=n(11),a=n(39),c=n(16);r({target:"Reflect",stat:!0,forced:n(5)((function(){Reflect.defineProperty(c.f({},1,{value:1}),1,{value:2})})),sham:!o},{defineProperty:function(e,t,n){i(e);var r=a(t,!0);i(n);try{return c.f(e,r,n),!0}catch(o){return!1}}})},function(e,t,n){"use strict";var r=n(4),o=n(11),i=n(23).f;r({target:"Reflect",stat:!0},{deleteProperty:function(e,t){var n=i(o(e),t);return!(n&&!n.configurable)&&delete e[t]}})},function(e,t,n){"use strict";var r=n(4),o=n(8),i=n(11),a=n(20),c=n(23),u=n(41);r({target:"Reflect",stat:!0},{get:function s(e,t){var n,r,l=arguments.length<3?e:arguments[2];return i(e)===l?e[t]:(n=c.f(e,t))?a(n,"value")?n.value:n.get===undefined?undefined:n.get.call(l):o(r=u(e))?s(r,t,l):void 0}})},function(e,t,n){"use strict";var r=n(4),o=n(10),i=n(11),a=n(23);r({target:"Reflect",stat:!0,sham:!o},{getOwnPropertyDescriptor:function(e,t){return a.f(i(e),t)}})},function(e,t,n){"use strict";var r=n(4),o=n(11),i=n(41);r({target:"Reflect",stat:!0,sham:!n(124)},{getPrototypeOf:function(e){return i(o(e))}})},function(e,t,n){"use strict";n(4)({target:"Reflect",stat:!0},{has:function(e,t){return t in e}})},function(e,t,n){"use strict";var r=n(4),o=n(11),i=Object.isExtensible;r({target:"Reflect",stat:!0},{isExtensible:function(e){return o(e),!i||i(e)}})},function(e,t,n){"use strict";n(4)({target:"Reflect",stat:!0},{ownKeys:n(113)})},function(e,t,n){"use strict";var r=n(4),o=n(40),i=n(11);r({target:"Reflect",stat:!0,sham:!n(76)},{preventExtensions:function(e){i(e);try{var t=o("Object","preventExtensions");return t&&t(e),!0}catch(n){return!1}}})},function(e,t,n){"use strict";var r=n(4),o=n(11),i=n(8),a=n(20),c=n(5),u=n(16),s=n(23),l=n(41),f=n(51);r({target:"Reflect",stat:!0,forced:c((function(){var e=u.f({},"a",{configurable:!0});return!1!==Reflect.set(l(e),"a",1,e)}))},{set:function d(e,t,n){var r,c,p=arguments.length<4?e:arguments[3],h=s.f(o(e),t);if(!h){if(i(c=l(e)))return d(c,t,n,p);h=f(0)}if(a(h,"value")){if(!1===h.writable||!i(p))return!1;if(r=s.f(p,t)){if(r.get||r.set||!1===r.writable)return!1;r.value=n,u.f(p,t,r)}else u.f(p,t,f(0,n));return!0}return h.set!==undefined&&(h.set.call(p,n),!0)}})},function(e,t,n){"use strict";var r=n(4),o=n(11),i=n(197),a=n(55);a&&r({target:"Reflect",stat:!0},{setPrototypeOf:function(e,t){o(e),i(t);try{return a(e,t),!0}catch(n){return!1}}})},function(e,t,n){"use strict";var r=n(10),o=n(7),i=n(70),a=n(90),c=n(16).f,u=n(52).f,s=n(94),l=n(77),f=n(95),d=n(27),p=n(5),h=n(33).set,g=n(61),v=n(15)("match"),m=o.RegExp,y=m.prototype,b=/a/g,w=/a/g,x=new m(b)!==b,_=f.UNSUPPORTED_Y;if(r&&i("RegExp",!x||_||p((function(){return w[v]=!1,m(b)!=b||m(w)==w||"/a/i"!=m(b,"i")})))){for(var E=function(e,t){var n,r=this instanceof E,o=s(e),i=t===undefined;if(!r&&o&&e.constructor===E&&i)return e;x?o&&!i&&(e=e.source):e instanceof E&&(i&&(t=l.call(e)),e=e.source),_&&(n=!!t&&t.indexOf("y")>-1)&&(t=t.replace(/y/g,""));var c=a(x?new m(e,t):m(e,t),r?this:y,E);return _&&n&&h(c,{sticky:n}),c},k=function(e){e in E||c(E,e,{configurable:!0,get:function(){return m[e]},set:function(t){m[e]=t}})},S=u(m),C=0;S.length>C;)k(S[C++]);y.constructor=E,E.prototype=y,d(o,"RegExp",E)}g("RegExp")},function(e,t,n){"use strict";var r=n(10),o=n(16),i=n(77),a=n(95).UNSUPPORTED_Y;r&&("g"!=/./g.flags||a)&&o.f(RegExp.prototype,"flags",{configurable:!0,get:i})},function(e,t,n){"use strict";var r=n(10),o=n(95).UNSUPPORTED_Y,i=n(16).f,a=n(33).get,c=RegExp.prototype;r&&o&&i(RegExp.prototype,"sticky",{configurable:!0,get:function(){if(this===c)return undefined;if(this instanceof RegExp)return!!a(this).sticky;throw TypeError("Incompatible receiver, RegExp required")}})},function(e,t,n){"use strict";n(96);var r,o,i=n(4),a=n(8),c=(r=!1,(o=/[ac]/).exec=function(){return r=!0,/./.exec.apply(this,arguments)},!0===o.test("abc")&&r),u=/./.test;i({target:"RegExp",proto:!0,forced:!c},{test:function(e){if("function"!=typeof this.exec)return u.call(this,e);var t=this.exec(e);if(null!==t&&!a(t))throw new Error("RegExp exec method returned something other than an Object or null");return!!t}})},function(e,t,n){"use strict";var r=n(27),o=n(11),i=n(5),a=n(77),c=RegExp.prototype,u=c.toString,s=i((function(){return"/a/b"!=u.call({source:"a",flags:"b"})})),l="toString"!=u.name;(s||l)&&r(RegExp.prototype,"toString",(function(){var e=o(this),t=String(e.source),n=e.flags;return"/"+t+"/"+String(n===undefined&&e instanceof RegExp&&!("flags"in c)?a.call(e):n)}),{unsafe:!0})},function(e,t,n){"use strict";var r=n(89),o=n(230);e.exports=r("Set",(function(e){return function(){return e(this,arguments.length?arguments[0]:undefined)}}),o)},function(e,t,n){"use strict";var r=n(4),o=n(131).codeAt;r({target:"String",proto:!0},{codePointAt:function(e){return o(this,e)}})},function(e,t,n){"use strict";var r,o=n(4),i=n(23).f,a=n(13),c=n(132),u=n(26),s=n(133),l=n(43),f="".endsWith,d=Math.min,p=s("endsWith");o({target:"String",proto:!0,forced:!!(l||p||(r=i(String.prototype,"endsWith"),!r||r.writable))&&!p},{endsWith:function(e){var t=String(u(this));c(e);var n=arguments.length>1?arguments[1]:undefined,r=a(t.length),o=n===undefined?r:d(a(n),r),i=String(e);return f?f.call(t,i,o):t.slice(o-i.length,o)===i}})},function(e,t,n){"use strict";var r=n(4),o=n(47),i=String.fromCharCode,a=String.fromCodePoint;r({target:"String",stat:!0,forced:!!a&&1!=a.length},{fromCodePoint:function(e){for(var t,n=[],r=arguments.length,a=0;r>a;){if(t=+arguments[a++],o(t,1114111)!==t)throw RangeError(t+" is not a valid code point");n.push(t<65536?i(t):i(55296+((t-=65536)>>10),t%1024+56320))}return n.join("")}})},function(e,t,n){"use strict";var r=n(4),o=n(132),i=n(26);r({target:"String",proto:!0,forced:!n(133)("includes")},{includes:function(e){return!!~String(i(this)).indexOf(o(e),arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(131).charAt,o=n(33),i=n(123),a=o.set,c=o.getterFor("String Iterator");i(String,"String",(function(e){a(this,{type:"String Iterator",string:String(e),index:0})}),(function(){var e,t=c(this),n=t.string,o=t.index;return o>=n.length?{value:undefined,done:!0}:(e=r(n,o),t.index+=e.length,{value:e,done:!1})}))},function(e,t,n){"use strict";var r=n(98),o=n(11),i=n(13),a=n(26),c=n(99),u=n(100);r("match",1,(function(e,t,n){return[function(t){var n=a(this),r=t==undefined?undefined:t[e];return r!==undefined?r.call(t,n):new RegExp(t)[e](String(n))},function(e){var r=n(t,e,this);if(r.done)return r.value;var a=o(e),s=String(this);if(!a.global)return u(a,s);var l=a.unicode;a.lastIndex=0;for(var f,d=[],p=0;null!==(f=u(a,s));){var h=String(f[0]);d[p]=h,""===h&&(a.lastIndex=c(s,i(a.lastIndex),l)),p++}return 0===p?null:d}]}))},function(e,t,n){"use strict";var r=n(4),o=n(195),i=n(26),a=n(13),c=n(29),u=n(11),s=n(37),l=n(94),f=n(77),d=n(32),p=n(5),h=n(15),g=n(45),v=n(99),m=n(33),y=n(43),b=h("matchAll"),w=m.set,x=m.getterFor("RegExp String Iterator"),_=RegExp.prototype,E=_.exec,k="".matchAll,S=!!k&&!p((function(){"a".matchAll(/./)})),C=o((function(e,t,n,r){w(this,{type:"RegExp String Iterator",regexp:e,string:t,global:n,unicode:r,done:!1})}),"RegExp String",(function(){var e=x(this);if(e.done)return{value:undefined,done:!0};var t=e.regexp,n=e.string,r=function(e,t){var n,r=e.exec;if("function"==typeof r){if("object"!=typeof(n=r.call(e,t)))throw TypeError("Incorrect exec result");return n}return E.call(e,t)}(t,n);return null===r?{value:undefined,done:e.done=!0}:e.global?(""==String(r[0])&&(t.lastIndex=v(n,a(t.lastIndex),e.unicode)),{value:r,done:!1}):(e.done=!0,{value:r,done:!1})})),N=function(e){var t,n,r,o,i,c,s=u(this),l=String(e);return t=g(s,RegExp),(n=s.flags)===undefined&&s instanceof RegExp&&!("flags"in _)&&(n=f.call(s)),r=n===undefined?"":String(n),o=new t(t===RegExp?s.source:s,r),i=!!~r.indexOf("g"),c=!!~r.indexOf("u"),o.lastIndex=a(s.lastIndex),new C(o,l,i,c)};r({target:"String",proto:!0,forced:S},{matchAll:function(e){var t,n,r,o=i(this);if(null!=e){if(l(e)&&!~String(i("flags"in _?e.flags:f.call(e))).indexOf("g"))throw TypeError("`.matchAll` does not allow non-global regexes");if(S)return k.apply(o,arguments);if((n=e[b])===undefined&&y&&"RegExp"==s(e)&&(n=N),null!=n)return c(n).call(e,o)}else if(S)return k.apply(o,arguments);return t=String(o),r=new RegExp(e,"g"),y?N.call(r,t):r[b](t)}}),y||b in _||d(_,b,N)},function(e,t,n){"use strict";var r=n(4),o=n(126).end;r({target:"String",proto:!0,forced:n(326)},{padEnd:function(e){return o(this,e,arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(84);e.exports=/Version\/10\.\d+(\.\d+)?( Mobile\/\w+)? Safari\//.test(r)},function(e,t,n){"use strict";var r=n(4),o=n(126).start;r({target:"String",proto:!0,forced:n(326)},{padStart:function(e){return o(this,e,arguments.length>1?arguments[1]:undefined)}})},function(e,t,n){"use strict";var r=n(4),o=n(31),i=n(13);r({target:"String",stat:!0},{raw:function(e){for(var t=o(e.raw),n=i(t.length),r=arguments.length,a=[],c=0;n>c;)a.push(String(t[c++])),c]*>)/g,g=/\$([$&'`]|\d\d?)/g;r("replace",2,(function(e,t,n,r){var v=r.REGEXP_REPLACE_SUBSTITUTES_UNDEFINED_CAPTURE,m=r.REPLACE_KEEPS_$0,y=v?"$":"$0";return[function(n,r){var o=u(this),i=n==undefined?undefined:n[e];return i!==undefined?i.call(n,o,r):t.call(String(o),n,r)},function(e,r){if(!v&&m||"string"==typeof r&&-1===r.indexOf(y)){var i=n(t,e,this,r);if(i.done)return i.value}var u=o(e),p=String(this),h="function"==typeof r;h||(r=String(r));var g=u.global;if(g){var w=u.unicode;u.lastIndex=0}for(var x=[];;){var _=l(u,p);if(null===_)break;if(x.push(_),!g)break;""===String(_[0])&&(u.lastIndex=s(p,a(u.lastIndex),w))}for(var E,k="",S=0,C=0;C=S&&(k+=p.slice(S,A)+L,S=A+N.length)}return k+p.slice(S)}];function b(e,n,r,o,a,c){var u=r+e.length,s=o.length,l=g;return a!==undefined&&(a=i(a),l=h),t.call(c,l,(function(t,i){var c;switch(i.charAt(0)){case"$":return"$";case"&":return e;case"`":return n.slice(0,r);case"'":return n.slice(u);case"<":c=a[i.slice(1,-1)];break;default:var l=+i;if(0===l)return t;if(l>s){var f=p(l/10);return 0===f?t:f<=s?o[f-1]===undefined?i.charAt(1):o[f-1]+i.charAt(1):t}c=o[l-1]}return c===undefined?"":c}))}}))},function(e,t,n){"use strict";var r=n(98),o=n(11),i=n(26),a=n(278),c=n(100);r("search",1,(function(e,t,n){return[function(t){var n=i(this),r=t==undefined?undefined:t[e];return r!==undefined?r.call(t,n):new RegExp(t)[e](String(n))},function(e){var r=n(t,e,this);if(r.done)return r.value;var i=o(e),u=String(this),s=i.lastIndex;a(s,0)||(i.lastIndex=0);var l=c(i,u);return a(i.lastIndex,s)||(i.lastIndex=s),null===l?-1:l.index}]}))},function(e,t,n){"use strict";var r=n(98),o=n(94),i=n(11),a=n(26),c=n(45),u=n(99),s=n(13),l=n(100),f=n(97),d=n(5),p=[].push,h=Math.min,g=!d((function(){return!RegExp(4294967295,"y")}));r("split",2,(function(e,t,n){var r;return r="c"=="abbc".split(/(b)*/)[1]||4!="test".split(/(?:)/,-1).length||2!="ab".split(/(?:ab)*/).length||4!=".".split(/(.?)(.?)/).length||".".split(/()()/).length>1||"".split(/.?/).length?function(e,n){var r=String(a(this)),i=n===undefined?4294967295:n>>>0;if(0===i)return[];if(e===undefined)return[r];if(!o(e))return t.call(r,e,i);for(var c,u,s,l=[],d=(e.ignoreCase?"i":"")+(e.multiline?"m":"")+(e.unicode?"u":"")+(e.sticky?"y":""),h=0,g=new RegExp(e.source,d+"g");(c=f.call(g,r))&&!((u=g.lastIndex)>h&&(l.push(r.slice(h,c.index)),c.length>1&&c.index=i));)g.lastIndex===c.index&&g.lastIndex++;return h===r.length?!s&&g.test("")||l.push(""):l.push(r.slice(h)),l.length>i?l.slice(0,i):l}:"0".split(undefined,0).length?function(e,n){return e===undefined&&0===n?[]:t.call(this,e,n)}:t,[function(t,n){var o=a(this),i=t==undefined?undefined:t[e];return i!==undefined?i.call(t,o,n):r.call(String(o),t,n)},function(e,o){var a=n(r,e,this,o,r!==t);if(a.done)return a.value;var f=i(e),d=String(this),p=c(f,RegExp),v=f.unicode,m=(f.ignoreCase?"i":"")+(f.multiline?"m":"")+(f.unicode?"u":"")+(g?"y":"g"),y=new p(g?f:"^(?:"+f.source+")",m),b=o===undefined?4294967295:o>>>0;if(0===b)return[];if(0===d.length)return null===l(y,d)?[d]:[];for(var w=0,x=0,_=[];x1?arguments[1]:undefined,t.length)),r=String(e);return f?f.call(t,r,n):t.slice(n,n+r.length)===r}})},function(e,t,n){"use strict";var r=n(4),o=n(64).trim;r({target:"String",proto:!0,forced:n(134)("trim")},{trim:function(){return o(this)}})},function(e,t,n){"use strict";var r=n(4),o=n(64).end,i=n(134)("trimEnd"),a=i?function(){return o(this)}:"".trimEnd;r({target:"String",proto:!0,forced:i},{trimEnd:a,trimRight:a})},function(e,t,n){"use strict";var r=n(4),o=n(64).start,i=n(134)("trimStart"),a=i?function(){return o(this)}:"".trimStart;r({target:"String",proto:!0,forced:i},{trimStart:a,trimLeft:a})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("anchor")},{anchor:function(e){return o(this,"a","name",e)}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("big")},{big:function(){return o(this,"big","","")}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("blink")},{blink:function(){return o(this,"blink","","")}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("bold")},{bold:function(){return o(this,"b","","")}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("fixed")},{fixed:function(){return o(this,"tt","","")}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("fontcolor")},{fontcolor:function(e){return o(this,"font","color",e)}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("fontsize")},{fontsize:function(e){return o(this,"font","size",e)}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("italics")},{italics:function(){return o(this,"i","","")}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("link")},{link:function(e){return o(this,"a","href",e)}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("small")},{small:function(){return o(this,"small","","")}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("strike")},{strike:function(){return o(this,"strike","","")}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("sub")},{sub:function(){return o(this,"sub","","")}})},function(e,t,n){"use strict";var r=n(4),o=n(34);r({target:"String",proto:!0,forced:n(35)("sup")},{sup:function(){return o(this,"sup","","")}})},function(e,t,n){"use strict";n(46)("Float32",(function(e){return function(t,n,r){return e(this,t,n,r)}}))},function(e,t,n){"use strict";var r=n(446);e.exports=function(e,t){var n=r(e);if(n%t)throw RangeError("Wrong offset");return n}},function(e,t,n){"use strict";var r=n(19),o=n(13),i=n(120),a=n(119),c=n(53),u=n(14).aTypedArrayConstructor;e.exports=function(e){var t,n,s,l,f,d,p=r(e),h=arguments.length,g=h>1?arguments[1]:undefined,v=g!==undefined,m=i(p);if(m!=undefined&&!a(m))for(d=(f=m.call(p)).next,p=[];!(l=d.call(f)).done;)p.push(l.value);for(v&&h>2&&(g=c(g,arguments[2],2)),n=o(p.length),s=new(u(this))(n),t=0;n>t;t++)s[t]=v?g(p[t],t):p[t];return s}},function(e,t,n){"use strict";n(46)("Float64",(function(e){return function(t,n,r){return e(this,t,n,r)}}))},function(e,t,n){"use strict";n(46)("Int8",(function(e){return function(t,n,r){return e(this,t,n,r)}}))},function(e,t,n){"use strict";n(46)("Int16",(function(e){return function(t,n,r){return e(this,t,n,r)}}))},function(e,t,n){"use strict";n(46)("Int32",(function(e){return function(t,n,r){return e(this,t,n,r)}}))},function(e,t,n){"use strict";n(46)("Uint8",(function(e){return function(t,n,r){return e(this,t,n,r)}}))},function(e,t,n){"use strict";n(46)("Uint8",(function(e){return function(t,n,r){return e(this,t,n,r)}}),!0)},function(e,t,n){"use strict";n(46)("Uint16",(function(e){return function(t,n,r){return e(this,t,n,r)}}))},function(e,t,n){"use strict";n(46)("Uint32",(function(e){return function(t,n,r){return e(this,t,n,r)}}))},function(e,t,n){"use strict";var r=n(14),o=n(180),i=r.aTypedArray;(0,r.exportTypedArrayMethod)("copyWithin",(function(e,t){return o.call(i(this),e,t,arguments.length>2?arguments[2]:undefined)}))},function(e,t,n){"use strict";var r=n(14),o=n(21).every,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("every",(function(e){return o(i(this),e,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(14),o=n(118),i=r.aTypedArray;(0,r.exportTypedArrayMethod)("fill",(function(e){return o.apply(i(this),arguments)}))},function(e,t,n){"use strict";var r=n(14),o=n(21).filter,i=n(45),a=r.aTypedArray,c=r.aTypedArrayConstructor;(0,r.exportTypedArrayMethod)("filter",(function(e){for(var t=o(a(this),e,arguments.length>1?arguments[1]:undefined),n=i(this,this.constructor),r=0,u=t.length,s=new(c(n))(u);u>r;)s[r]=t[r++];return s}))},function(e,t,n){"use strict";var r=n(14),o=n(21).find,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("find",(function(e){return o(i(this),e,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(14),o=n(21).findIndex,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("findIndex",(function(e){return o(i(this),e,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(14),o=n(21).forEach,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("forEach",(function(e){o(i(this),e,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(135);(0,n(14).exportTypedArrayStaticMethod)("from",n(352),r)},function(e,t,n){"use strict";var r=n(14),o=n(69).includes,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("includes",(function(e){return o(i(this),e,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(14),o=n(69).indexOf,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("indexOf",(function(e){return o(i(this),e,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(7),o=n(14),i=n(122),a=n(15)("iterator"),c=r.Uint8Array,u=i.values,s=i.keys,l=i.entries,f=o.aTypedArray,d=o.exportTypedArrayMethod,p=c&&c.prototype[a],h=!!p&&("values"==p.name||p.name==undefined),g=function(){return u.call(f(this))};d("entries",(function(){return l.call(f(this))})),d("keys",(function(){return s.call(f(this))})),d("values",g,!h),d(a,g,!h)},function(e,t,n){"use strict";var r=n(14),o=r.aTypedArray,i=r.exportTypedArrayMethod,a=[].join;i("join",(function(e){return a.apply(o(this),arguments)}))},function(e,t,n){"use strict";var r=n(14),o=n(200),i=r.aTypedArray;(0,r.exportTypedArrayMethod)("lastIndexOf",(function(e){return o.apply(i(this),arguments)}))},function(e,t,n){"use strict";var r=n(14),o=n(21).map,i=n(45),a=r.aTypedArray,c=r.aTypedArrayConstructor;(0,r.exportTypedArrayMethod)("map",(function(e){return o(a(this),e,arguments.length>1?arguments[1]:undefined,(function(e,t){return new(c(i(e,e.constructor)))(t)}))}))},function(e,t,n){"use strict";var r=n(14),o=n(135),i=r.aTypedArrayConstructor;(0,r.exportTypedArrayStaticMethod)("of",(function(){for(var e=0,t=arguments.length,n=new(i(this))(t);t>e;)n[e]=arguments[e++];return n}),o)},function(e,t,n){"use strict";var r=n(14),o=n(87).left,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("reduce",(function(e){return o(i(this),e,arguments.length,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(14),o=n(87).right,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("reduceRight",(function(e){return o(i(this),e,arguments.length,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(14),o=r.aTypedArray,i=r.exportTypedArrayMethod,a=Math.floor;i("reverse",(function(){for(var e,t=o(this).length,n=a(t/2),r=0;r1?arguments[1]:undefined,1),n=this.length,r=a(e),c=o(r.length),s=0;if(c+t>n)throw RangeError("Wrong length");for(;si;)l[i]=n[i++];return l}),i((function(){new Int8Array(1).slice()})))},function(e,t,n){"use strict";var r=n(14),o=n(21).some,i=r.aTypedArray;(0,r.exportTypedArrayMethod)("some",(function(e){return o(i(this),e,arguments.length>1?arguments[1]:undefined)}))},function(e,t,n){"use strict";var r=n(14),o=r.aTypedArray,i=r.exportTypedArrayMethod,a=[].sort;i("sort",(function(e){return a.call(o(this),e)}))},function(e,t,n){"use strict";var r=n(14),o=n(13),i=n(47),a=n(45),c=r.aTypedArray;(0,r.exportTypedArrayMethod)("subarray",(function(e,t){var n=c(this),r=n.length,u=i(e,r);return new(a(n,n.constructor))(n.buffer,n.byteOffset+u*n.BYTES_PER_ELEMENT,o((t===undefined?r:i(t,r))-u))}))},function(e,t,n){"use strict";var r=n(7),o=n(14),i=n(5),a=r.Int8Array,c=o.aTypedArray,u=o.exportTypedArrayMethod,s=[].toLocaleString,l=[].slice,f=!!a&&i((function(){s.call(new a(1))}));u("toLocaleString",(function(){return s.apply(f?l.call(c(this)):c(this),arguments)}),i((function(){return[1,2].toLocaleString()!=new a([1,2]).toLocaleString()}))||!i((function(){a.prototype.toLocaleString.call([1,2])})))},function(e,t,n){"use strict";var r=n(14).exportTypedArrayMethod,o=n(5),i=n(7).Uint8Array,a=i&&i.prototype||{},c=[].toString,u=[].join;o((function(){c.call({})}))&&(c=function(){return u.call(this)});var s=a.toString!=c;r("toString",c,s)},function(e,t,n){"use strict";var r,o=n(7),i=n(75),a=n(56),c=n(89),u=n(387),s=n(8),l=n(33).enforce,f=n(155),d=!o.ActiveXObject&&"ActiveXObject"in o,p=Object.isExtensible,h=function(e){return function(){return e(this,arguments.length?arguments[0]:undefined)}},g=e.exports=c("WeakMap",h,u);if(f&&d){r=u.getConstructor(h,"WeakMap",!0),a.REQUIRED=!0;var v=g.prototype,m=v["delete"],y=v.has,b=v.get,w=v.set;i(v,{"delete":function(e){if(s(e)&&!p(e)){var t=l(this);return t.frozen||(t.frozen=new r),m.call(this,e)||t.frozen["delete"](e)}return m.call(this,e)},has:function(e){if(s(e)&&!p(e)){var t=l(this);return t.frozen||(t.frozen=new r),y.call(this,e)||t.frozen.has(e)}return y.call(this,e)},get:function(e){if(s(e)&&!p(e)){var t=l(this);return t.frozen||(t.frozen=new r),y.call(this,e)?b.call(this,e):t.frozen.get(e)}return b.call(this,e)},set:function(e,t){if(s(e)&&!p(e)){var n=l(this);n.frozen||(n.frozen=new r),y.call(this,e)?w.call(this,e,t):n.frozen.set(e,t)}else w.call(this,e,t);return this}})}},function(e,t,n){"use strict";var r=n(75),o=n(56).getWeakData,i=n(11),a=n(8),c=n(62),u=n(63),s=n(21),l=n(20),f=n(33),d=f.set,p=f.getterFor,h=s.find,g=s.findIndex,v=0,m=function(e){return e.frozen||(e.frozen=new y)},y=function(){this.entries=[]},b=function(e,t){return h(e.entries,(function(e){return e[0]===t}))};y.prototype={get:function(e){var t=b(this,e);if(t)return t[1]},has:function(e){return!!b(this,e)},set:function(e,t){var n=b(this,e);n?n[1]=t:this.entries.push([e,t])},"delete":function(e){var t=g(this.entries,(function(t){return t[0]===e}));return~t&&this.entries.splice(t,1),!!~t}},e.exports={getConstructor:function(e,t,n,s){var f=e((function(e,r){c(e,f,t),d(e,{type:t,id:v++,frozen:undefined}),r!=undefined&&u(r,e[s],e,n)})),h=p(t),g=function(e,t,n){var r=h(e),a=o(i(t),!0);return!0===a?m(r).set(t,n):a[r.id]=n,e};return r(f.prototype,{"delete":function(e){var t=h(this);if(!a(e))return!1;var n=o(e);return!0===n?m(t)["delete"](e):n&&l(n,t.id)&&delete n[t.id]},has:function(e){var t=h(this);if(!a(e))return!1;var n=o(e);return!0===n?m(t).has(e):n&&l(n,t.id)}}),r(f.prototype,n?{get:function(e){var t=h(this);if(a(e)){var n=o(e);return!0===n?m(t).get(e):n?n[t.id]:undefined}},set:function(e,t){return g(this,e,t)}}:{add:function(e){return g(this,e,!0)}}),f}}},function(e,t,n){"use strict";n(89)("WeakSet",(function(e){return function(){return e(this,arguments.length?arguments[0]:undefined)}}),n(387))},function(e,t,n){"use strict";var r=n(4),o=n(7),i=n(129);r({global:!0,bind:!0,enumerable:!0,forced:!o.setImmediate||!o.clearImmediate},{setImmediate:i.set,clearImmediate:i.clear})},function(e,t,n){"use strict";var r=n(4),o=n(7),i=n(294),a=n(37),c=o.process,u="process"==a(c);r({global:!0,enumerable:!0,noTargetGet:!0},{queueMicrotask:function(e){var t=u&&c.domain;i(t?t.bind(e):e)}})},function(e,t,n){"use strict";var r=n(4),o=n(7),i=n(84),a=[].slice,c=function(e){return function(t,n){var r=arguments.length>2,o=r?a.call(arguments,2):undefined;return e(r?function(){("function"==typeof t?t:Function(t)).apply(this,o)}:t,n)}};r({global:!0,bind:!0,forced:/MSIE .\./.test(i)},{setTimeout:c(o.setTimeout),setInterval:c(o.setInterval)})},function(e,t,n){"use strict";var r=function(e){var t=Object.prototype,n=t.hasOwnProperty,r="function"==typeof Symbol?Symbol:{},o=r.iterator||"@@iterator",i=r.asyncIterator||"@@asyncIterator",a=r.toStringTag||"@@toStringTag";function c(e,t,n,r){var o=t&&t.prototype instanceof l?t:l,i=Object.create(o.prototype),a=new _(r||[]);return i._invoke=function(e,t,n){var r="suspendedStart";return function(o,i){if("executing"===r)throw new Error("Generator is already running");if("completed"===r){if("throw"===o)throw i;return k()}for(n.method=o,n.arg=i;;){var a=n.delegate;if(a){var c=b(a,n);if(c){if(c===s)continue;return c}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if("suspendedStart"===r)throw r="completed",n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);r="executing";var l=u(e,t,n);if("normal"===l.type){if(r=n.done?"completed":"suspendedYield",l.arg===s)continue;return{value:l.arg,done:n.done}}"throw"===l.type&&(r="completed",n.method="throw",n.arg=l.arg)}}}(e,n,a),i}function u(e,t,n){try{return{type:"normal",arg:e.call(t,n)}}catch(r){return{type:"throw",arg:r}}}e.wrap=c;var s={};function l(){}function f(){}function d(){}var p={};p[o]=function(){return this};var h=Object.getPrototypeOf,g=h&&h(h(E([])));g&&g!==t&&n.call(g,o)&&(p=g);var v=d.prototype=l.prototype=Object.create(p);function m(e){["next","throw","return"].forEach((function(t){e[t]=function(e){return this._invoke(t,e)}}))}function y(e,t){var r;this._invoke=function(o,i){function a(){return new t((function(r,a){!function c(r,o,i,a){var s=u(e[r],e,o);if("throw"!==s.type){var l=s.arg,f=l.value;return f&&"object"==typeof f&&n.call(f,"__await")?t.resolve(f.__await).then((function(e){c("next",e,i,a)}),(function(e){c("throw",e,i,a)})):t.resolve(f).then((function(e){l.value=e,i(l)}),(function(e){return c("throw",e,i,a)}))}a(s.arg)}(o,i,r,a)}))}return r=r?r.then(a,a):a()}}function b(e,t){var n=e.iterator[t.method];if(void 0===n){if(t.delegate=null,"throw"===t.method){if(e.iterator["return"]&&(t.method="return",t.arg=void 0,b(e,t),"throw"===t.method))return s;t.method="throw",t.arg=new TypeError("The iterator does not provide a 'throw' method")}return s}var r=u(n,e.iterator,t.arg);if("throw"===r.type)return t.method="throw",t.arg=r.arg,t.delegate=null,s;var o=r.arg;return o?o.done?(t[e.resultName]=o.value,t.next=e.nextLoc,"return"!==t.method&&(t.method="next",t.arg=void 0),t.delegate=null,s):o:(t.method="throw",t.arg=new TypeError("iterator result is not an object"),t.delegate=null,s)}function w(e){var t={tryLoc:e[0]};1 in e&&(t.catchLoc=e[1]),2 in e&&(t.finallyLoc=e[2],t.afterLoc=e[3]),this.tryEntries.push(t)}function x(e){var t=e.completion||{};t.type="normal",delete t.arg,e.completion=t}function _(e){this.tryEntries=[{tryLoc:"root"}],e.forEach(w,this),this.reset(!0)}function E(e){if(e){var t=e[o];if(t)return t.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length)){var r=-1,i=function t(){for(;++r=0;--o){var i=this.tryEntries[o],a=i.completion;if("root"===i.tryLoc)return r("end");if(i.tryLoc<=this.prev){var c=n.call(i,"catchLoc"),u=n.call(i,"finallyLoc");if(c&&u){if(this.prev=0;--r){var o=this.tryEntries[r];if(o.tryLoc<=this.prev&&n.call(o,"finallyLoc")&&this.prev=0;--t){var n=this.tryEntries[t];if(n.finallyLoc===e)return this.complete(n.completion,n.afterLoc),x(n),s}},"catch":function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var n=this.tryEntries[t];if(n.tryLoc===e){var r=n.completion;if("throw"===r.type){var o=r.arg;x(n)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(e,t,n){return this.delegate={iterator:E(e),resultName:t,nextLoc:n},"next"===this.method&&(this.arg=void 0),s}},e}(e.exports);try{regeneratorRuntime=r}catch(o){Function("r","regeneratorRuntime = r")(r)}},function(e,t,n){"use strict";!function(t,n){var r,o,i=t.html5||{},a=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,c=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,u=0,s={};function l(){var e=h.elements;return"string"==typeof e?e.split(" "):e}function f(e){var t=s[e._html5shiv];return t||(t={},u++,e._html5shiv=u,s[u]=t),t}function d(e,t,r){return t||(t=n),o?t.createElement(e):(r||(r=f(t)),!(i=r.cache[e]?r.cache[e].cloneNode():c.test(e)?(r.cache[e]=r.createElem(e)).cloneNode():r.createElem(e)).canHaveChildren||a.test(e)||i.tagUrn?i:r.frag.appendChild(i));var i}function p(e){e||(e=n);var t=f(e);return!h.shivCSS||r||t.hasCSS||(t.hasCSS=!!function(e,t){var n=e.createElement("p"),r=e.getElementsByTagName("head")[0]||e.documentElement;return n.innerHTML="x",r.insertBefore(n.lastChild,r.firstChild)}(e,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),o||function(e,t){t.cache||(t.cache={},t.createElem=e.createElement,t.createFrag=e.createDocumentFragment,t.frag=t.createFrag()),e.createElement=function(n){return h.shivMethods?d(n,e,t):t.createElem(n)},e.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+l().join().replace(/[\w\-:]+/g,(function(e){return t.createElem(e),t.frag.createElement(e),'c("'+e+'")'}))+");return n}")(h,t.frag)}(e,t),e}!function(){try{var e=n.createElement("a");e.innerHTML=" ",r="hidden"in e,o=1==e.childNodes.length||function(){n.createElement("a");var e=n.createDocumentFragment();return"undefined"==typeof e.cloneNode||"undefined"==typeof e.createDocumentFragment||"undefined"==typeof e.createElement}()}catch(t){r=!0,o=!0}}();var h={elements:i.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:"3.7.3",shivCSS:!1!==i.shivCSS,supportsUnknownElements:o,shivMethods:!1!==i.shivMethods,type:"default",shivDocument:p,createElement:d,createDocumentFragment:function(e,t){if(e||(e=n),o)return e.createDocumentFragment();for(var r=(t=t||f(e)).frag.cloneNode(),i=0,a=l(),c=a.length;i3?c(a):null,b=String(a.key),w=String(a.char),x=a.location,_=a.keyCode||(a.keyCode=b)&&b.charCodeAt(0)||0,E=a.charCode||(a.charCode=w)&&w.charCodeAt(0)||0,k=a.bubbles,S=a.cancelable,C=a.repeat,N=a.locale,A=a.view||e;if(a.which||(a.which=a.keyCode),"initKeyEvent"in d)d.initKeyEvent(t,k,S,A,p,g,h,v,_,E);else if(0>>0),t=Element.prototype,n=t.querySelector,r=t.querySelectorAll;function o(t,n,r){t.setAttribute(e,null);var o=n.call(t,String(r).replace(/(^|,\s*)(:scope([ >]|$))/g,(function(t,n,r,o){return n+"["+e+"]"+(o||" ")})));return t.removeAttribute(e),o}t.querySelector=function(e){return o(this,n,e)},t.querySelectorAll=function(e){return o(this,r,e)}}()}}(window),function(e){var t=e.WeakMap||function(){var e,t=0,n=!1,r=!1;function o(t,o,i){r=i,n=!1,e=undefined,t.dispatchEvent(o)}function i(e){this.value=e}function c(){t++,this.__ce__=new a("@DOMMap:"+t+Math.random())}return i.prototype.handleEvent=function(t){n=!0,r?t.currentTarget.removeEventListener(t.type,this,!1):e=this.value},c.prototype={constructor:c,"delete":function(e){return o(e,this.__ce__,!0),n},get:function(t){o(t,this.__ce__,!1);var n=e;return e=undefined,n},has:function(e){return o(e,this.__ce__,!1),n},set:function(e,t){return o(e,this.__ce__,!0),e.addEventListener(this.__ce__.type,new i(t),!1),this}},c}();function n(){}function r(e,t,n){function o(e){o.once&&(e.currentTarget.removeEventListener(e.type,t,o),o.removed=!0),o.passive&&(e.preventDefault=r.preventDefault),"function"==typeof o.callback?o.callback.call(this,e):o.callback&&o.callback.handleEvent(e),o.passive&&delete e.preventDefault}return o.type=e,o.callback=t,o.capture=!!n.capture,o.passive=!!n.passive,o.once=!!n.once,o.removed=!1,o}n.prototype=(Object.create||Object)(null),r.preventDefault=function(){};var o,i,a=e.CustomEvent,c=e.dispatchEvent,u=e.addEventListener,s=e.removeEventListener,l=0,f=function(){l++},d=[].indexOf||function(e){for(var t=this.length;t--&&this[t]!==e;);return t},p=function(e){return"".concat(e.capture?"1":"0",e.passive?"1":"0",e.once?"1":"0")};try{u("_",f,{once:!0}),c(new a("_")),c(new a("_")),s("_",f,{once:!0})}catch(h){}1!==l&&(i=new t,o=function(e){if(e){var t=e.prototype;t.addEventListener=function(e){return function(t,o,a){if(a&&"boolean"!=typeof a){var c,u,s,l=i.get(this),f=p(a);l||i.set(this,l=new n),t in l||(l[t]={handler:[],wrap:[]}),u=l[t],(c=d.call(u.handler,o))<0?(c=u.handler.push(o)-1,u.wrap[c]=s=new n):s=u.wrap[c],f in s||(s[f]=r(t,o,a),e.call(this,t,s[f],s[f].capture))}else e.call(this,t,o,a)}}(t.addEventListener),t.removeEventListener=function(e){return function(t,n,r){if(r&&"boolean"!=typeof r){var o,a,c,u,s=i.get(this);if(s&&t in s&&(c=s[t],-1<(a=d.call(c.handler,n))&&(o=p(r))in(u=c.wrap[a]))){for(o in e.call(this,t,u[o],u[o].capture),delete u[o],u)return;c.handler.splice(a,1),c.wrap.splice(a,1),0===c.handler.length&&delete s[t]}}else e.call(this,t,n,r)}}(t.removeEventListener)}},e.EventTarget?o(EventTarget):(o(e.Text),o(e.Element||e.HTMLElement),o(e.HTMLDocument),o(e.Window||{prototype:e}),o(e.XMLHttpRequest)))}(window)},function(e,t,n){"use strict";!function(e){if("undefined"!=typeof e.setAttribute){var t=function(e){return e.replace(/-[a-z]/g,(function(e){return e[1].toUpperCase()}))};e.setProperty=function(e,n){var r=t(e);if(!n)return this.removeAttribute(r);var o=String(n);return this.setAttribute(r,o)},e.getPropertyValue=function(e){var n=t(e);return this.getAttribute(n)||null},e.removeProperty=function(e){var n=t(e),r=this.getAttribute(n);return this.removeAttribute(n),r}}}(CSSStyleDeclaration.prototype)},function(e,t,n){"use strict";window.Int32Array||(window.Int32Array=Array)},function(e,t,n){"use strict";t.__esModule=!0,t.EventEmitter=void 0;var r=function(){function e(){this.listeners={}}var t=e.prototype;return t.on=function(e,t){this.listeners[e]=this.listeners[e]||[],this.listeners[e].push(t)},t.off=function(e,t){var n=this.listeners[e];if(!n)throw new Error('There is no listeners for "'+e+'"');this.listeners[e]=n.filter((function(e){return e!==t}))},t.emit=function(e){var t=this.listeners[e];if(t){for(var n=arguments.length,r=new Array(n>1?n-1:0),o=1;ou&&(o[a]=u-t[a],i=!0)}return[i,o]};t.dragStartHandler=function(e){d.log("drag start"),h=!0,u=[window.screenLeft-e.screenX,window.screenTop-e.screenY],document.addEventListener("mousemove",T),document.addEventListener("mouseup",A),T(e)};var A=function M(e){d.log("drag end"),T(e),document.removeEventListener("mousemove",T),document.removeEventListener("mouseup",M),h=!1,k()},T=function(e){h&&(e.preventDefault(),b((0,o.vecAdd)([e.screenX,e.screenY],u)))};t.resizeStartHandler=function(e,t){return function(n){s=[e,t],d.log("resize start",s),g=!0,u=[window.screenLeft-n.screenX,window.screenTop-n.screenY],l=[window.innerWidth,window.innerHeight],document.addEventListener("mousemove",I),document.addEventListener("mouseup",O),I(n)}};var O=function L(e){d.log("resize end",f),I(e),document.removeEventListener("mousemove",I),document.removeEventListener("mouseup",L),g=!1,k()},I=function(e){g&&(e.preventDefault(),(f=(0,o.vecAdd)(l,(0,o.vecMultiply)(s,(0,o.vecAdd)([e.screenX,e.screenY],(0,o.vecInverse)([window.screenLeft,window.screenTop]),u,[1,1]))))[0]=Math.max(f[0],150),f[1]=Math.max(f[1],50),w(f))}},function(e,t,n){"use strict";t.__esModule=!0,t.focusWindow=t.focusMap=void 0;t.focusMap=function(){Byond.winset("mapwindow.map",{focus:!0})};t.focusWindow=function(){Byond.winset(window.__windowId__,{focus:!0})}},function(e,t,n){"use strict";t.__esModule=!0,t.selectDebug=void 0;t.selectDebug=function(e){return e.debug}},function(e,t,n){"use strict";t.__esModule=!0,t.ButtonInput=t.ButtonConfirm=t.ButtonCheckbox=t.Button=void 0;var r=n(0),o=n(6),i=n(136),a=n(24),c=n(18),u=n(105),s=n(405);function l(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,e.__proto__=t}function f(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}var d=(0,a.createLogger)("Button"),p=function(e){var t=e.className,n=e.fluid,a=e.icon,l=e.iconRotation,p=e.iconSpin,h=e.color,g=e.disabled,v=e.selected,m=e.tooltip,y=e.tooltipPosition,b=e.ellipsis,w=e.compact,x=e.circular,_=e.content,E=e.children,k=e.onclick,S=e.onClick,C=f(e,["className","fluid","icon","iconRotation","iconSpin","color","disabled","selected","tooltip","tooltipPosition","ellipsis","compact","circular","content","children","onclick","onClick"]),N=!(!_&&!E);return k&&d.warn("Lowercase 'onclick' is not supported on Button and lowercase prop names are discouraged in general. Please use a camelCase'onClick' instead and read: https://infernojs.org/docs/guides/event-handling"),(0,r.normalizeProps)((0,r.createComponentVNode)(2,c.Box,Object.assign({className:(0,o.classes)(["Button",n&&"Button--fluid",g&&"Button--disabled",v&&"Button--selected",N&&"Button--hasContent",b&&"Button--ellipsis",x&&"Button--circular",w&&"Button--compact",h&&"string"==typeof h?"Button--color--"+h:"Button--color--default",t]),tabIndex:!g&&"0",unselectable:Byond.IS_LTE_IE8,onClick:function(e){!g&&S&&S(e)},onKeyDown:function(e){var t=window.event?e.which:e.keyCode;if(t===i.KEY_SPACE||t===i.KEY_ENTER)return e.preventDefault(),void(!g&&S&&S(e));t!==i.KEY_ESCAPE||e.preventDefault()}},C,{children:[a&&(0,r.createComponentVNode)(2,u.Icon,{name:a,rotation:l,spin:p}),_,E,m&&(0,r.createComponentVNode)(2,s.Tooltip,{content:m,position:y})]})))};t.Button=p,p.defaultHooks=o.pureComponentHooks;var h=function(e){var t=e.checked,n=f(e,["checked"]);return(0,r.normalizeProps)((0,r.createComponentVNode)(2,p,Object.assign({color:"transparent",icon:t?"check-square-o":"square-o",selected:t},n)))};t.ButtonCheckbox=h,p.Checkbox=h;var g=function(e){function t(){var t;return(t=e.call(this)||this).state={clickedOnce:!1},t.handleClick=function(){t.state.clickedOnce&&t.setClickedOnce(!1)},t}l(t,e);var n=t.prototype;return n.setClickedOnce=function(e){var t=this;this.setState({clickedOnce:e}),e?setTimeout((function(){return window.addEventListener("click",t.handleClick)})):window.removeEventListener("click",this.handleClick)},n.render=function(){var e=this,t=this.props,n=t.confirmContent,o=void 0===n?"Confirm?":n,i=t.confirmColor,a=void 0===i?"bad":i,c=t.confirmIcon,u=t.icon,s=t.color,l=t.content,d=t.onClick,h=f(t,["confirmContent","confirmColor","confirmIcon","icon","color","content","onClick"]);return(0,r.normalizeProps)((0,r.createComponentVNode)(2,p,Object.assign({content:this.state.clickedOnce?o:l,icon:this.state.clickedOnce?c:u,color:this.state.clickedOnce?a:s,onClick:function(){return e.state.clickedOnce?d():e.setClickedOnce(!0)}},h)))},t}(r.Component);t.ButtonConfirm=g,p.Confirm=g;var v=function(e){function t(){var t;return(t=e.call(this)||this).inputRef=(0,r.createRef)(),t.state={inInput:!1},t}l(t,e);var n=t.prototype;return n.setInInput=function(e){if(this.setState({inInput:e}),this.inputRef){var t=this.inputRef.current;if(e){t.value=this.props.currentValue||"";try{t.focus(),t.select()}catch(n){}}}},n.commitResult=function(e){if(this.inputRef){var t=this.inputRef.current;if(""!==t.value)return void this.props.onCommit(e,t.value);if(!this.props.defaultValue)return;this.props.onCommit(e,this.props.defaultValue)}},n.render=function(){var e=this,t=this.props,n=t.fluid,a=t.content,l=t.icon,d=t.iconRotation,p=t.iconSpin,h=t.tooltip,g=t.tooltipPosition,v=t.color,m=void 0===v?"default":v,y=(t.placeholder,t.maxLength,f(t,["fluid","content","icon","iconRotation","iconSpin","tooltip","tooltipPosition","color","placeholder","maxLength"]));return(0,r.normalizeProps)((0,r.createComponentVNode)(2,c.Box,Object.assign({className:(0,o.classes)(["Button",n&&"Button--fluid","Button--color--"+m])},y,{onClick:function(){return e.setInInput(!0)},children:[l&&(0,r.createComponentVNode)(2,u.Icon,{name:l,rotation:d,spin:p}),(0,r.createVNode)(1,"div",null,a,0),(0,r.createVNode)(64,"input","NumberInput__input",null,1,{style:{display:this.state.inInput?undefined:"none","text-align":"left"},onBlur:function(t){e.state.inInput&&(e.setInInput(!1),e.commitResult(t))},onKeyDown:function(t){if(t.keyCode===i.KEY_ENTER)return e.setInInput(!1),void e.commitResult(t);t.keyCode===i.KEY_ESCAPE&&e.setInInput(!1)}},null,this.inputRef),h&&(0,r.createComponentVNode)(2,s.Tooltip,{content:h,position:g})]})))},t}(r.Component);t.ButtonInput=v,p.Input=v},function(e,t,n){"use strict";t.__esModule=!0,t.Tooltip=void 0;var r=n(0),o=n(6);t.Tooltip=function(e){var t=e.content,n=e.position,i=void 0===n?"bottom":n,a="string"==typeof t&&t.length>35;return(0,r.createVNode)(1,"div",(0,o.classes)(["Tooltip",a&&"Tooltip--long",i&&"Tooltip--"+i]),null,1,{"data-tooltip":t})}},function(e,t,n){"use strict";t.__esModule=!0,t.Dimmer=void 0;var r=n(0),o=n(6),i=n(18);t.Dimmer=function(e){var t=e.className,n=e.children,a=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}(e,["className","children"]);return(0,r.normalizeProps)((0,r.createComponentVNode)(2,i.Box,Object.assign({className:(0,o.classes)(["Dimmer"].concat(t))},a,{children:(0,r.createVNode)(1,"div","Dimmer__inner",n,0)})))}},function(e,t,n){"use strict";t.__esModule=!0,t.Divider=void 0;var r=n(0),o=n(6);t.Divider=function(e){var t=e.vertical,n=e.hidden;return(0,r.createVNode)(1,"div",(0,o.classes)(["Divider",n&&"Divider--hidden",t?"Divider--vertical":"Divider--horizontal"]))}},function(e,t,n){"use strict";t.__esModule=!0,t.Input=t.toInputValue=void 0;var r=n(0),o=n(6),i=n(18),a=n(78);function c(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r=0||(o[n]=e[n]);return o}var u=function(e){return"number"!=typeof e&&"string"!=typeof e?"":String(e)};t.toInputValue=u;var s=function(e){var t,n;function s(){var t;return(t=e.call(this)||this).inputRef=(0,r.createRef)(),t.state={editing:!1},t.handleInput=function(e){var n=t.state.editing,r=t.props.onInput;n||t.setEditing(!0),r&&r(e,e.target.value)},t.handleFocus=function(e){t.state.editing||t.setEditing(!0)},t.handleBlur=function(e){var n=t.state.editing,r=t.props.onChange;n&&(t.setEditing(!1),r&&r(e,e.target.value))},t.handleKeyDown=function(e){var n=t.props,r=n.onInput,o=n.onChange,i=n.onEnter;return e.keyCode===a.KEY_ENTER?(t.setEditing(!1),o&&o(e,e.target.value),r&&r(e,e.target.value),i&&i(e,e.target.value),void(t.props.selfClear?e.target.value="":e.target.blur())):e.keyCode===a.KEY_ESCAPE?(t.setEditing(!1),e.target.value=u(t.props.value),void e.target.blur()):void 0},t}n=e,(t=s).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n;var l=s.prototype;return l.componentDidMount=function(){var e=this.props.value,t=this.inputRef.current;t&&(t.value=u(e)),this.props.autoFocus&&setTimeout((function(){return t.focus()}),1)},l.componentDidUpdate=function(e,t){var n=this.state.editing,r=e.value,o=this.props.value,i=this.inputRef.current;i&&!n&&r!==o&&(i.value=u(o))},l.setEditing=function(e){this.setState({editing:e})},l.render=function(){var e=this.props,t=(e.selfClear,e.onInput,e.onChange,e.onEnter,e.value,e.maxLength),n=e.placeholder,a=c(e,["selfClear","onInput","onChange","onEnter","value","maxLength","placeholder"]),u=a.className,s=a.fluid,l=a.monospace,f=c(a,["className","fluid","monospace"]);return(0,r.normalizeProps)((0,r.createComponentVNode)(2,i.Box,Object.assign({className:(0,o.classes)(["Input",s&&"Input--fluid",l&&"Input--monospace",u])},f,{children:[(0,r.createVNode)(1,"div","Input__baseline",".",16),(0,r.createVNode)(64,"input","Input__input",null,1,{placeholder:n,onInput:this.handleInput,onFocus:this.handleFocus,onBlur:this.handleBlur,onKeyDown:this.handleKeyDown,maxLength:t},null,this.inputRef)]})))},s}(r.Component);t.Input=s},function(e,t,n){"use strict";t.__esModule=!0,t.LabeledListDivider=t.LabeledListItem=t.LabeledList=void 0;var r=n(0),o=n(6),i=n(18),a=n(407),c=function(e){var t=e.children;return(0,r.createVNode)(1,"table","LabeledList",t,0)};t.LabeledList=c,c.defaultHooks=o.pureComponentHooks;var u=function(e){var t=e.className,n=e.label,a=e.labelColor,c=void 0===a?"label":a,u=e.color,s=e.textAlign,l=e.buttons,f=e.content,d=e.children;return(0,r.createVNode)(1,"tr",(0,o.classes)(["LabeledList__row",t]),[(0,r.createComponentVNode)(2,i.Box,{as:"td",color:c,className:(0,o.classes)(["LabeledList__cell","LabeledList__label"]),children:n?n+":":null}),(0,r.createComponentVNode)(2,i.Box,{as:"td",color:u,textAlign:s,className:(0,o.classes)(["LabeledList__cell","LabeledList__content"]),colSpan:l?undefined:2,children:[f,d]}),l&&(0,r.createVNode)(1,"td","LabeledList__cell LabeledList__buttons",l,0)],0)};t.LabeledListItem=u,u.defaultHooks=o.pureComponentHooks;var s=function(e){var t=e.size?(0,i.unit)(Math.max(0,e.size-1)):0;return(0,r.createVNode)(1,"tr","LabeledList__row",(0,r.createVNode)(1,"td",null,(0,r.createComponentVNode)(2,a.Divider),2,{colSpan:3,style:{"padding-top":t,"padding-bottom":t}}),2)};t.LabeledListDivider=s,s.defaultHooks=o.pureComponentHooks,c.Item=u,c.Divider=s},function(e,t,n){"use strict";t.__esModule=!0,t.Window=void 0;var r=n(0),o=n(6),i=n(22),a=n(17),c=n(2),u=n(1),s=n(42),l=n(138),f=(n(411),n(401)),d=n(24),p=n(144);var h=(0,d.createLogger)("Window"),g=[400,600],v=function(e){var t,n;function u(){return e.apply(this,arguments)||this}n=e,(t=u).prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n;var d=u.prototype;return d.componentDidMount=function(){var e,t=(0,c.useBackend)(this.context),n=t.config;if(!t.suspended){h.log("mounting");var r=Object.assign({size:g},n.window);this.props.width&&this.props.height&&(r.size=[this.props.width,this.props.height]),(null==(e=n.window)?void 0:e.key)&&(0,f.setWindowKey)(n.window.key),(0,f.recallWindowGeometry)(r)}},d.render=function(){var e,t=this.props,n=t.resizable,u=t.theme,d=t.title,g=t.children,v=(0,c.useBackend)(this.context),m=v.config,b=v.suspended,w=(0,l.useDebug)(this.context).debugLayout,x=(0,i.useDispatch)(this.context),_=null==(e=m.window)?void 0:e.fancy,E=m.user&&(m.user.observer?m.status=0||(o[n]=e[n]);return o}(e,["className","fitted","children"]);return(0,r.normalizeProps)((0,r.createComponentVNode)(2,p.Layout.Content,Object.assign({className:(0,o.classes)(["Window__content",t])},a,{children:n&&i||(0,r.createVNode)(1,"div","Window__contentPadding",i,0)})))};var m=function(e){switch(e){case s.UI_INTERACTIVE:return"good";case s.UI_UPDATE:return"average";case s.UI_DISABLED:default:return"bad"}},y=function(e,t){var n=e.className,c=e.title,s=e.status,l=e.fancy,f=e.onDragStart,d=e.onClose;(0,i.useDispatch)(t);return(0,r.createVNode)(1,"div",(0,o.classes)(["TitleBar",n]),[s===undefined&&(0,r.createComponentVNode)(2,u.Icon,{className:"TitleBar__statusIcon",name:"tools",opacity:.5})||(0,r.createComponentVNode)(2,u.Icon,{className:"TitleBar__statusIcon",color:m(s),name:"eye"}),(0,r.createVNode)(1,"div","TitleBar__title","string"==typeof c&&c===c.toLowerCase()&&(0,a.toTitleCase)(c)||c,0),(0,r.createVNode)(1,"div","TitleBar__dragZone",null,1,{onMousedown:function(e){return l&&f(e)}}),!1,!!l&&(0,r.createVNode)(1,"div","TitleBar__close TitleBar__clickable",Byond.IS_LTE_IE8?"x":"\xd7",0,{onclick:d})],0)}},function(e,t,n){"use strict";t.__esModule=!0,t.openExternalBrowser=t.toggleDebugLayout=t.toggleKitchenSink=void 0;var r=n(22),o=(0,r.createAction)("debug/toggleKitchenSink");t.toggleKitchenSink=o;var i=(0,r.createAction)("debug/toggleDebugLayout");t.toggleDebugLayout=i;var a=(0,r.createAction)("debug/openExternalBrowser");t.openExternalBrowser=a},,,,,,,,,,,,,,,,,,,,,,,function(e,t,n){"use strict";var r=n(21).forEach,o=n(44),i=n(30),a=o("forEach"),c=i("forEach");e.exports=a&&c?[].forEach:function(e){return r(this,e,arguments.length>1?arguments[1]:undefined)}},function(e,t,n){"use strict";var r=n(53),o=n(19),i=n(191),a=n(119),c=n(13),u=n(54),s=n(120);e.exports=function(e){var t,n,l,f,d,p,h=o(e),g="function"==typeof this?this:Array,v=arguments.length,m=v>1?arguments[1]:undefined,y=m!==undefined,b=s(h),w=0;if(y&&(m=r(m,v>2?arguments[2]:undefined,2)),b==undefined||g==Array&&a(b))for(n=new g(t=c(h.length));t>w;w++)p=y?m(h[w],w):h[w],u(n,w,p);else for(d=(f=b.call(h)).next,n=new g;!(l=d.call(f)).done;w++)p=y?i(f,m,[l.value,w],!0):l.value,u(n,w,p);return n.length=w,n}},function(e,t,n){"use strict";var r=Math.abs,o=Math.pow,i=Math.floor,a=Math.log,c=Math.LN2;e.exports={pack:function(e,t,n){var u,s,l,f=new Array(n),d=8*n-t-1,p=(1<>1,g=23===t?o(2,-24)-o(2,-77):0,v=e<0||0===e&&1/e<0?1:0,m=0;for((e=r(e))!=e||e===1/0?(s=e!=e?1:0,u=p):(u=i(a(e)/c),e*(l=o(2,-u))<1&&(u--,l*=2),(e+=u+h>=1?g/l:g*o(2,1-h))*l>=2&&(u++,l/=2),u+h>=p?(s=0,u=p):u+h>=1?(s=(e*l-1)*o(2,t),u+=h):(s=e*o(2,h-1)*o(2,t),u=0));t>=8;f[m++]=255&s,s/=256,t-=8);for(u=u<0;f[m++]=255&u,u/=256,d-=8);return f[--m]|=128*v,f},unpack:function(e,t){var n,r=e.length,i=8*r-t-1,a=(1<>1,u=i-7,s=r-1,l=e[s--],f=127&l;for(l>>=7;u>0;f=256*f+e[s],s--,u-=8);for(n=f&(1<<-u)-1,f>>=-u,u+=t;u>0;n=256*n+e[s],s--,u-=8);if(0===f)f=1-c;else{if(f===a)return n?NaN:l?-1/0:1/0;n+=o(2,t),f-=c}return(l?-1:1)*n*o(2,f-t)}}},function(e,t,n){"use strict";var r=n(5),o=n(126).start,i=Math.abs,a=Date.prototype,c=a.getTime,u=a.toISOString;e.exports=r((function(){return"0385-07-25T07:06:39.999Z"!=u.call(new Date(-50000000000001))}))||!r((function(){u.call(new Date(NaN))}))?function(){if(!isFinite(c.call(this)))throw RangeError("Invalid time value");var e=this.getUTCFullYear(),t=this.getUTCMilliseconds(),n=e<0?"-":e>9999?"+":"";return n+o(i(e),n?6:4,0)+"-"+o(this.getUTCMonth()+1,2,0)+"-"+o(this.getUTCDate(),2,0)+"T"+o(this.getUTCHours(),2,0)+":"+o(this.getUTCMinutes(),2,0)+":"+o(this.getUTCSeconds(),2,0)+"."+o(t,3,0)+"Z"}:u},function(e,t,n){"use strict";var r=n(11),o=n(39);e.exports=function(e){if("string"!==e&&"number"!==e&&"default"!==e)throw TypeError("Incorrect hint");return o(r(this),"number"!==e)}},function(e,t,n){"use strict";var r=n(128),o=Math.abs,i=Math.pow,a=i(2,-52),c=i(2,-23),u=i(2,127)*(2-c),s=i(2,-126);e.exports=Math.fround||function(e){var t,n,i=o(e),l=r(e);return iu||n!=n?l*Infinity:l*n}},function(e,t,n){"use strict";var r=n(7).isFinite;e.exports=Number.isFinite||function(e){return"number"==typeof e&&r(e)}},function(e,t,n){"use strict";var r=n(7),o=n(64).trim,i=n(92),a=r.parseFloat,c=1/a(i+"-0")!=-Infinity;e.exports=c?function(e){var t=o(String(e)),n=a(t);return 0===n&&"-"==t.charAt(0)?-0:n}:a},function(e,t,n){"use strict";var r=n(37);e.exports=function(e){if("number"!=typeof e&&"Number"!=r(e))throw TypeError("Incorrect invocation");return+e}},function(e,t,n){"use strict";var r=n(10),o=n(5),i=n(71),a=n(115),c=n(82),u=n(19),s=n(66),l=Object.assign,f=Object.defineProperty;e.exports=!l||o((function(){if(r&&1!==l({b:1},l(f({},"a",{enumerable:!0,get:function(){f(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var e={},t={},n=Symbol();return e[n]=7,"abcdefghijklmnopqrst".split("").forEach((function(e){t[e]=e})),7!=l({},e)[n]||"abcdefghijklmnopqrst"!=i(l({},t)).join("")}))?function(e,t){for(var n=u(e),o=arguments.length,l=1,f=a.f,d=c.f;o>l;)for(var p,h=s(arguments[l++]),g=f?i(h).concat(f(h)):i(h),v=g.length,m=0;v>m;)p=g[m++],r&&!d.call(h,p)||(n[p]=h[p]);return n}:l},function(e,t,n){"use strict";var r=n(121),o=n(85);e.exports=r?{}.toString:function(){return"[object "+o(this)+"]"}},function(e,t,n){"use strict";var r=n(7);e.exports=function(e,t){var n=r.console;n&&n.error&&(1===arguments.length?n.error(e):n.error(e,t))}},function(e,t,n){"use strict";var r=n(38);e.exports=function(e){var t=r(e);if(t<0)throw RangeError("The argument can't be less than 0");return t}},function(e,t,n){"use strict";t.__esModule=!0,t._CI=Ae,t._HI=B,t._M=Te,t._MCCC=Le,t._ME=Ie,t._MFCC=Ve,t._MP=Ce,t._MR=ye,t.__render=je,t.createComponentVNode=function(e,t,n,r,o){var a=new T(1,null,null,e=function(e,t){if(12&e)return e;if(t.prototype&&t.prototype.render)return 4;if(t.render)return 32776;return 8}(e,t),r,function(e,t,n){var r=(32768&e?t.render:t).defaultProps;if(i(r))return n;if(i(n))return l(r,null);return N(n,r)}(e,t,n),function(e,t,n){if(4&e)return n;var r=(32768&e?t.render:t).defaultHooks;if(i(r))return n;if(i(n))return r;return N(n,r)}(e,t,o),t);k.createVNode&&k.createVNode(a);return a},t.createFragment=M,t.createPortal=function(e,t){var n=B(e);return O(1024,1024,null,n,0,null,n.key,t)},t.createRef=function(){return{current:null}},t.createRenderer=function(e){return function(t,n,r,o){e||(e=t),Fe(n,e,r,o)}},t.createTextVNode=I,t.createVNode=O,t.directClone=L,t.findDOMfromVNode=b,t.forwardRef=function(e){return{render:e}},t.getFlagsForElementVnode=function(e){switch(e){case"svg":return 32;case"input":return 64;case"select":return 256;case"textarea":return 128;case"$F":return 8192;default:return 1}},t.linkEvent=function(e,t){if(c(t))return{data:e,event:t};return null},t.normalizeProps=function(e){var t=e.props;if(t){var n=e.flags;481&n&&(void 0!==t.children&&i(e.children)&&P(e,t.children),void 0!==t.className&&(e.className=t.className||null,t.className=undefined)),void 0!==t.key&&(e.key=t.key,t.key=undefined),void 0!==t.ref&&(e.ref=8&n?l(e.ref,t.ref):t.ref,t.ref=undefined)}return e},t.render=Fe,t.rerender=He,t.version=t.options=t.Fragment=t.EMPTY_OBJ=t.Component=void 0;var r=Array.isArray;function o(e){var t=typeof e;return"string"===t||"number"===t}function i(e){return null==e}function a(e){return null===e||!1===e||!0===e||void 0===e}function c(e){return"function"==typeof e}function u(e){return"string"==typeof e}function s(e){return null===e}function l(e,t){var n={};if(e)for(var r in e)n[r]=e[r];if(t)for(var o in t)n[o]=t[o];return n}function f(e){return!s(e)&&"object"==typeof e}var d={};t.EMPTY_OBJ=d;function p(e){return e.substr(2).toLowerCase()}function h(e,t){e.appendChild(t)}function g(e,t,n){s(n)?h(e,t):e.insertBefore(t,n)}function v(e,t){e.removeChild(t)}function m(e){for(var t=0;t0,h=s(d),g=u(d)&&"$"===d[0];p||h||g?(n=n||t.slice(0,l),(p||g)&&(f=L(f)),(h||g)&&(f.key="$"+l),n.push(f)):n&&n.push(f),f.flags|=65536}}i=0===(n=n||t).length?1:8}else(n=t).flags|=65536,81920&t.flags&&(n=L(t)),i=2;return e.children=n,e.childFlags=i,e}function B(e){return a(e)||o(e)?I(e,null):r(e)?M(e,0,null):16384&e.flags?L(e):e}var D="http://www.w3.org/1999/xlink",j="http://www.w3.org/XML/1998/namespace",F={"xlink:actuate":D,"xlink:arcrole":D,"xlink:href":D,"xlink:role":D,"xlink:show":D,"xlink:title":D,"xlink:type":D,"xml:base":j,"xml:lang":j,"xml:space":j};function K(e){return{onClick:e,onDblClick:e,onFocusIn:e,onFocusOut:e,onKeyDown:e,onKeyPress:e,onKeyUp:e,onMouseDown:e,onMouseMove:e,onMouseUp:e,onTouchEnd:e,onTouchMove:e,onTouchStart:e}}var z=K(0),Y=K(null),U=K(!0);function $(e,t){var n=t.$EV;return n||(n=t.$EV=K(null)),n[e]||1==++z[e]&&(Y[e]=function(e){var t="onClick"===e||"onDblClick"===e?function(e){return function(t){0===t.button?W(t,!0,e,Z(t)):t.stopPropagation()}}(e):function(e){return function(t){W(t,!1,e,Z(t))}}(e);return document.addEventListener(p(e),t),t}(e)),n}function H(e,t){var n=t.$EV;n&&n[e]&&(0==--z[e]&&(document.removeEventListener(p(e),Y[e]),Y[e]=null),n[e]=null)}function W(e,t,n,r){var o=function(e){return c(e.composedPath)?e.composedPath()[0]:e.target}(e);do{if(t&&o.disabled)return;var i=o.$EV;if(i){var a=i[n];if(a&&(r.dom=o,a.event?a.event(a.data,e):a(e),e.cancelBubble))return}o=o.parentNode}while(!s(o))}function q(){this.cancelBubble=!0,this.immediatePropagationStopped||this.stopImmediatePropagation()}function G(){return this.defaultPrevented}function X(){return this.cancelBubble}function Z(e){var t={dom:document};return e.isDefaultPrevented=G,e.isPropagationStopped=X,e.stopPropagation=q,Object.defineProperty(e,"currentTarget",{configurable:!0,get:function(){return t.dom}}),t}function Q(e,t,n){if(e[t]){var r=e[t];r.event?r.event(r.data,n):r(n)}else{var o=t.toLowerCase();e[o]&&e[o](n)}}function J(e,t){var n=function(n){var r=this.$V;if(r){var o=r.props||d,i=r.dom;if(u(e))Q(o,e,n);else for(var a=0;a-1&&t.options[a]&&(c=t.options[a].value),n&&i(c)&&(c=e.defaultValue),ae(r,c)}}var se,le,fe=J("onInput",pe),de=J("onChange");function pe(e,t,n){var r=e.value,o=t.value;if(i(r)){if(n){var a=e.defaultValue;i(a)||a===o||(t.defaultValue=a,t.value=a)}}else o!==r&&(t.defaultValue=r,t.value=r)}function he(e,t,n,r,o,i){64&e?ie(r,n):256&e?ue(r,n,o,t):128&e&&pe(r,n,o),i&&(n.$V=t)}function ge(e,t,n){64&e?function(e,t){te(t.type)?(ee(e,"change",re),ee(e,"click",oe)):ee(e,"input",ne)}(t,n):256&e?function(e){ee(e,"change",ce)}(t):128&e&&function(e,t){ee(e,"input",fe),t.onChange&&ee(e,"change",de)}(t,n)}function ve(e){return e.type&&te(e.type)?!i(e.checked):!i(e.value)}function me(e){e&&!A(e,null)&&e.current&&(e.current=null)}function ye(e,t,n){e&&(c(e)||void 0!==e.current)&&n.push((function(){A(e,t)||void 0===e.current||(e.current=t)}))}function be(e,t){we(e),w(e,t)}function we(e){var t,n=e.flags,r=e.children;if(481&n){t=e.ref;var o=e.props;me(t);var a=e.childFlags;if(!s(o))for(var u=Object.keys(o),l=0,f=u.length;l0;for(var c in a&&(i=ve(n))&&ge(t,r,n),n)Se(c,null,n[c],r,o,i,null);a&&he(t,e,r,n,!0,i)}function Ne(e,t,n){var r=B(e.render(t,e.state,n)),o=n;return c(e.getChildContext)&&(o=l(n,e.getChildContext())),e.$CX=o,r}function Ae(e,t,n,r,o,i){var a=new t(n,r),u=a.$N=Boolean(t.getDerivedStateFromProps||a.getSnapshotBeforeUpdate);if(a.$SVG=o,a.$L=i,e.children=a,a.$BS=!1,a.context=r,a.props===d&&(a.props=n),u)a.state=_(a,n,a.state);else if(c(a.componentWillMount)){a.$BR=!0,a.componentWillMount();var l=a.$PS;if(!s(l)){var f=a.state;if(s(f))a.state=l;else for(var p in l)f[p]=l[p];a.$PS=null}a.$BR=!1}return a.$LI=Ne(a,n,r),a}function Te(e,t,n,r,o,i){var a=e.flags|=16384;481&a?Ie(e,t,n,r,o,i):4&a?function(e,t,n,r,o,i){var a=Ae(e,e.type,e.props||d,n,r,i);Te(a.$LI,t,a.$CX,r,o,i),Le(e.ref,a,i)}(e,t,n,r,o,i):8&a?(!function(e,t,n,r,o,i){Te(e.children=B(function(e,t){return 32768&e.flags?e.type.render(e.props||d,e.ref,t):e.type(e.props||d,t)}(e,n)),t,n,r,o,i)}(e,t,n,r,o,i),Ve(e,i)):512&a||16&a?Oe(e,t,o):8192&a?function(e,t,n,r,o,i){var a=e.children,c=e.childFlags;12&c&&0===a.length&&(c=e.childFlags=2,a=e.children=V());2===c?Te(a,n,o,r,o,i):Me(a,n,t,r,o,i)}(e,n,t,r,o,i):1024&a&&function(e,t,n,r,o){Te(e.children,e.ref,t,!1,null,o);var i=V();Oe(i,n,r),e.dom=i.dom}(e,n,t,o,i)}function Oe(e,t,n){var r=e.dom=document.createTextNode(e.children);s(t)||g(t,r,n)}function Ie(e,t,n,r,o,a){var c=e.flags,u=e.props,l=e.className,f=e.children,d=e.childFlags,p=e.dom=function(e,t){return t?document.createElementNS("http://www.w3.org/2000/svg",e):document.createElement(e)}(e.type,r=r||(32&c)>0);if(i(l)||""===l||(r?p.setAttribute("class",l):p.className=l),16===d)S(p,f);else if(1!==d){var h=r&&"foreignObject"!==e.type;2===d?(16384&f.flags&&(e.children=f=L(f)),Te(f,p,n,h,null,a)):8!==d&&4!==d||Me(f,p,n,h,null,a)}s(t)||g(t,p,o),s(u)||Ce(e,c,u,p,r),ye(e.ref,p,a)}function Me(e,t,n,r,o,i){for(var a=0;a0,s!==l){var h=s||d;if((c=l||d)!==d)for(var g in(f=(448&o)>0)&&(p=ve(c)),c){var v=h[g],m=c[g];v!==m&&Se(g,v,m,u,r,p,e)}if(h!==d)for(var y in h)i(c[y])&&!i(h[y])&&Se(y,h[y],null,u,r,p,e)}var b=t.children,w=t.className;e.className!==w&&(i(w)?u.removeAttribute("class"):r?u.setAttribute("class",w):u.className=w);4096&o?function(e,t){e.textContent!==t&&(e.textContent=t)}(u,b):Pe(e.childFlags,t.childFlags,e.children,b,u,n,r&&"foreignObject"!==t.type,null,e,a);f&&he(o,t,u,c,!1,p);var x=t.ref,_=e.ref;_!==x&&(me(_),ye(x,u,a))}(e,t,r,o,p,f):4&p?function(e,t,n,r,o,i,a){var u=t.children=e.children;if(s(u))return;u.$L=a;var f=t.props||d,p=t.ref,h=e.ref,g=u.state;if(!u.$N){if(c(u.componentWillReceiveProps)){if(u.$BR=!0,u.componentWillReceiveProps(f,r),u.$UN)return;u.$BR=!1}s(u.$PS)||(g=l(g,u.$PS),u.$PS=null)}Be(u,g,f,n,r,o,!1,i,a),h!==p&&(me(h),ye(p,u,a))}(e,t,n,r,o,u,f):8&p?function(e,t,n,r,o,a,u){var s=!0,l=t.props||d,f=t.ref,p=e.props,h=!i(f),g=e.children;h&&c(f.onComponentShouldUpdate)&&(s=f.onComponentShouldUpdate(p,l));if(!1!==s){h&&c(f.onComponentWillUpdate)&&f.onComponentWillUpdate(p,l);var v=t.type,m=B(32768&t.flags?v.render(l,f,r):v(l,r));Re(g,m,n,r,o,a,u),t.children=m,h&&c(f.onComponentDidUpdate)&&f.onComponentDidUpdate(p,l)}else t.children=g}(e,t,n,r,o,u,f):16&p?function(e,t){var n=t.children,r=t.dom=e.dom;n!==e.children&&(r.nodeValue=n)}(e,t):512&p?t.dom=e.dom:8192&p?function(e,t,n,r,o,i){var a=e.children,c=t.children,u=e.childFlags,s=t.childFlags,l=null;12&s&&0===c.length&&(s=t.childFlags=2,c=t.children=V());var f=0!=(2&s);if(12&u){var d=a.length;(8&u&&8&s||f||!f&&c.length>d)&&(l=b(a[d-1],!1).nextSibling)}Pe(u,s,a,c,n,r,o,l,e,i)}(e,t,n,r,o,f):function(e,t,n,r){var o=e.ref,i=t.ref,c=t.children;if(Pe(e.childFlags,t.childFlags,e.children,c,o,n,!1,null,e,r),t.dom=e.dom,o!==i&&!a(c)){var u=c.dom;v(o,u),h(i,u)}}(e,t,r,f)}function Pe(e,t,n,r,o,i,a,c,u,s){switch(e){case 2:switch(t){case 2:Re(n,r,o,i,a,c,s);break;case 1:be(n,o);break;case 16:we(n),S(o,r);break;default:!function(e,t,n,r,o,i){we(e),Me(t,n,r,o,b(e,!0),i),w(e,n)}(n,r,o,i,a,s)}break;case 1:switch(t){case 2:Te(r,o,i,a,c,s);break;case 1:break;case 16:S(o,r);break;default:Me(r,o,i,a,c,s)}break;case 16:switch(t){case 16:!function(e,t,n){e!==t&&(""!==e?n.firstChild.nodeValue=t:S(n,t))}(n,r,o);break;case 2:_e(o),Te(r,o,i,a,c,s);break;case 1:_e(o);break;default:_e(o),Me(r,o,i,a,c,s)}break;default:switch(t){case 16:xe(n),S(o,r);break;case 2:Ee(o,u,n),Te(r,o,i,a,c,s);break;case 1:Ee(o,u,n);break;default:var l=0|n.length,f=0|r.length;0===l?f>0&&Me(r,o,i,a,c,s):0===f?Ee(o,u,n):8===t&&8===e?function(e,t,n,r,o,i,a,c,u,s){var l,f,d=i-1,p=a-1,h=0,g=e[h],v=t[h];e:{for(;g.key===v.key;){if(16384&v.flags&&(t[h]=v=L(v)),Re(g,v,n,r,o,c,s),e[h]=v,++h>d||h>p)break e;g=e[h],v=t[h]}for(g=e[d],v=t[p];g.key===v.key;){if(16384&v.flags&&(t[p]=v=L(v)),Re(g,v,n,r,o,c,s),e[d]=v,d--,p--,h>d||h>p)break e;g=e[d],v=t[p]}}if(h>d){if(h<=p)for(f=(l=p+1)p)for(;h<=d;)be(e[h++],n);else!function(e,t,n,r,o,i,a,c,u,s,l,f,d){var p,h,g,v=0,m=c,y=c,w=i-c+1,_=a-c+1,E=new Int32Array(_+1),k=w===r,S=!1,C=0,N=0;if(o<4||(w|_)<32)for(v=m;v<=i;++v)if(p=e[v],N<_){for(c=y;c<=a;c++)if(h=t[c],p.key===h.key){if(E[c-y]=v+1,k)for(k=!1;m