diff --git a/SQL/database_changelog.txt b/SQL/database_changelog.txt
index 96165390988..58efbbb5435 100644
--- a/SQL/database_changelog.txt
+++ b/SQL/database_changelog.txt
@@ -2,22 +2,36 @@ Any time you make a change to the schema files, remember to increment the databa
Make sure to also update `DB_MAJOR_VERSION` and `DB_MINOR_VERSION`, which can be found in `code/__DEFINES/subsystem.dm`.
-The latest database version is 5.24 (5.22 for /tg/); The query to update the schema revision table is:
+The latest database version is 5.25 (5.23 for /tg/); The query to update the schema revision table is:
-INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 24);
+INSERT INTO `schema_revision` (`major`, `minor`) VALUES (5, 25);
or
-INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 24);
+INSERT INTO `SS13_schema_revision` (`major`, `minor`) VALUES (5, 25);
In any query remember to add a prefix to the table names if you use one.
+-----------------------------------------------------
+Version 5.25, 28 December 2022, by Mothblocks
+Added `tutorial_completions` to mark what ckeys have completed contextual tutorials.
+
+```
+CREATE TABLE `tutorial_completions` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `ckey` VARCHAR(32) NOT NULL,
+ `tutorial_key` VARCHAR(64) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE INDEX `ckey_tutorial_unique` (`ckey`, `tutorial_key`));
+```
+
+-----------------------------------------------------
Version 5.24, 22 December 2021, by Mothblocks
Fixes a bug in `telemetry_connections` that limited the range of IPs.
```
ALTER TABLE `telemetry_connections` MODIFY COLUMN `address` INT(10) UNSIGNED NOT NULL;
```
------------------------------------------------------
+-----------------------------------------------------
Version 5.23, 15 December 2021, by Mothblocks
Adds `telemetry_connections` table for tracking tgui telemetry.
diff --git a/SQL/tgstation_schema.sql b/SQL/tgstation_schema.sql
index 52f999c4101..ed6ebb3c992 100644
--- a/SQL/tgstation_schema.sql
+++ b/SQL/tgstation_schema.sql
@@ -716,6 +716,14 @@ CREATE TABLE `telemetry_connections` (
UNIQUE INDEX `unique_constraints` (`ckey` , `telemetry_ckey` , `address` , `computer_id`)
);
+DROP TABLE IF EXISTS `tutorial_completions`;
+CREATE TABLE `tutorial_completions` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `ckey` VARCHAR(32) NOT NULL,
+ `tutorial_key` VARCHAR(64) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE INDEX `ckey_tutorial_unique` (`ckey`, `tutorial_key`));
+
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
diff --git a/SQL/tgstation_schema_prefixed.sql b/SQL/tgstation_schema_prefixed.sql
index 3a9773b9dc5..e69cd9ee046 100644
--- a/SQL/tgstation_schema_prefixed.sql
+++ b/SQL/tgstation_schema_prefixed.sql
@@ -700,6 +700,14 @@ CREATE TABLE `SS13_telemetry_connections` (
UNIQUE INDEX `unique_constraints` (`ckey` , `telemetry_ckey` , `address` , `computer_id`)
);
+DROP TABLE IF EXISTS `SS13_tutorial_completions`;
+CREATE TABLE `SS13_tutorial_completions` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `ckey` VARCHAR(32) NOT NULL,
+ `tutorial_key` VARCHAR(64) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE INDEX `ckey_tutorial_unique` (`ckey`, `tutorial_key`));
+
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
diff --git a/code/__DEFINES/colors.dm b/code/__DEFINES/colors.dm
index 1a025c502ac..9e1fe18270a 100644
--- a/code/__DEFINES/colors.dm
+++ b/code/__DEFINES/colors.dm
@@ -28,6 +28,7 @@
#define COLOR_ALMOST_BLACK "#333333"
#define COLOR_FULL_TONER_BLACK "#101010"
#define COLOR_PRISONER_BLACK "#292929"
+#define COLOR_NEARLY_ALL_BLACK "#111111"
#define COLOR_BLACK "#000000"
#define COLOR_HALF_TRANSPARENT_BLACK "#0000007A"
diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm
index b878fb21823..5e5864665be 100644
--- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm
+++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm
@@ -133,5 +133,9 @@
/// From /mob/living/befriend() : (mob/living/new_friend)
#define COMSIG_LIVING_BEFRIENDED "living_befriended"
+
+/// From /obj/item/proc/pickup(): (/obj/item/picked_up_item)
+#define COMSIG_LIVING_PICKED_UP_ITEM "living_picked_up_item"
+
/// From /mob/living/unfriend() : (mob/living/old_friend)
#define COMSIG_LIVING_UNFRIENDED "living_unfriended"
diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm
index 1cac348d152..005d6594257 100644
--- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm
+++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_main.dm
@@ -116,9 +116,12 @@
#define MOB_DEADSAY_SIGNAL_INTERCEPT (1<<0)
///from /mob/living/emote(): ()
#define COMSIG_MOB_EMOTE "mob_emote"
-///from base of mob/swap_hand(): (obj/item)
-#define COMSIG_MOB_SWAP_HANDS "mob_swap_hands"
+///from base of mob/swap_hand(): (obj/item/currently_held_item)
+#define COMSIG_MOB_SWAPPING_HANDS "mob_swapping_hands"
#define COMPONENT_BLOCK_SWAP (1<<0)
+/// from base of mob/swap_hand(): ()
+/// Performed after the hands are swapped.
+#define COMSIG_MOB_SWAP_HANDS "mob_swap_hands"
///from base of /mob/verb/pointed: (atom/A)
#define COMSIG_MOB_POINTED "mob_pointed"
///Mob is trying to open the wires of a target [/atom], from /datum/wires/interactable(): (atom/target)
@@ -171,5 +174,9 @@
///from living/flash_act(), when a mob is successfully flashed.
#define COMSIG_MOB_FLASHED "mob_flashed"
+
/// from mob/get_status_tab_items(): (list/items)
#define COMSIG_MOB_GET_STATUS_TAB_ITEMS "mob_get_status_tab_items"
+
+/// from mob/proc/dropItemToGround()
+#define COMSIG_MOB_DROPPING_ITEM "mob_dropping_item"
diff --git a/code/__DEFINES/layers.dm b/code/__DEFINES/layers.dm
index a06085bbf04..2ecdda04738 100644
--- a/code/__DEFINES/layers.dm
+++ b/code/__DEFINES/layers.dm
@@ -240,6 +240,9 @@
///Layer for screentips
#define SCREENTIP_LAYER 4
+/// Layer for tutorial instructions
+#define TUTORIAL_INSTRUCTIONS_LAYER 5
+
#define LOBBY_BACKGROUND_LAYER 3
#define LOBBY_BUTTON_LAYER 4
diff --git a/code/__DEFINES/matrices.dm b/code/__DEFINES/matrices.dm
index b7240751b63..1bb86f9ff47 100644
--- a/code/__DEFINES/matrices.dm
+++ b/code/__DEFINES/matrices.dm
@@ -1,2 +1,3 @@
-/// Helper macro for compile time translation
+/// Helper macro for creating a matrix at the given offsets.
+/// Works at compile time.
#define TRANSLATE_MATRIX(offset_x, offset_y) matrix(1, 0, (offset_x), 0, 1, (offset_y))
diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm
index 3e6e3d385c8..68d7bfc6c4a 100644
--- a/code/__DEFINES/subsystems.dm
+++ b/code/__DEFINES/subsystems.dm
@@ -20,7 +20,7 @@
*
* make sure you add an update to the schema_version stable in the db changelog
*/
-#define DB_MINOR_VERSION 24
+#define DB_MINOR_VERSION 25
//! ## Timing subsystem
diff --git a/code/__HELPERS/screen_objs.dm b/code/__HELPERS/screen_objs.dm
index aa86b6d5795..7debecf816f 100644
--- a/code/__HELPERS/screen_objs.dm
+++ b/code/__HELPERS/screen_objs.dm
@@ -18,14 +18,26 @@
y += view_size[2]
if(findtext(screen_loc, "SOUTH"))
y += world.icon_size
- // Cut out everything we just parsed
- screen_loc = cut_relative_direction(screen_loc)
var/list/x_and_y = splittext(screen_loc, ",")
+
var/list/x_pack = splittext(x_and_y[1], ":")
var/list/y_pack = splittext(x_and_y[2], ":")
- x += text2num(x_pack[1]) * world.icon_size
- y += text2num(y_pack[1]) * world.icon_size
+
+ var/x_coord = x_pack[1]
+ var/y_coord = y_pack[1]
+
+ if (findtext(x_coord, "CENTER"))
+ x += view_size[1] / 2
+
+ if (findtext(y_coord, "CENTER"))
+ y += view_size[2] / 2
+
+ x_coord = text2num(cut_relative_direction(x_coord))
+ y_coord = text2num(cut_relative_direction(y_coord))
+
+ x += x_coord * world.icon_size
+ y += y_coord * world.icon_size
if(length(x_pack) > 1)
x += text2num(x_pack[2])
diff --git a/code/_onclick/item_attack.dm b/code/_onclick/item_attack.dm
index 0399d7146d5..18984c7b27f 100644
--- a/code/_onclick/item_attack.dm
+++ b/code/_onclick/item_attack.dm
@@ -59,6 +59,14 @@
if (after_attack_secondary_result == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN || after_attack_secondary_result == SECONDARY_ATTACK_CONTINUE_CHAIN)
return TRUE
+ var/afterattack_result = afterattack(target, user, TRUE, params)
+
+ if (!(afterattack_result & AFTERATTACK_PROCESSED_ITEM) && isitem(target))
+ if (isnull(user.get_inactive_held_item()))
+ SStutorials.suggest_tutorial(user, /datum/tutorial/switch_hands, params2list(params))
+ else
+ SStutorials.suggest_tutorial(user, /datum/tutorial/drop, params2list(params))
+
return afterattack(target, user, TRUE, params) == TRUE
/// Called when the item is in the active hand, and clicked; alternately, there is an 'activate held object' verb or you can hit pagedown.
diff --git a/code/controllers/configuration/entries/game_options.dm b/code/controllers/configuration/entries/game_options.dm
index e822a4d88e3..032ff23afee 100644
--- a/code/controllers/configuration/entries/game_options.dm
+++ b/code/controllers/configuration/entries/game_options.dm
@@ -414,3 +414,4 @@
/datum/config_entry/flag/disallow_circuit_sounds
+/datum/config_entry/flag/give_tutorials_without_db
diff --git a/code/controllers/subsystem/tutorials.dm b/code/controllers/subsystem/tutorials.dm
new file mode 100644
index 00000000000..3df3df116cb
--- /dev/null
+++ b/code/controllers/subsystem/tutorials.dm
@@ -0,0 +1,111 @@
+/// Namespace for housing code relating to giving contextual tutorials to users.
+SUBSYSTEM_DEF(tutorials)
+ name = "Tutorials"
+ flags = SS_NO_FIRE
+
+ /// A mapping of /datum/tutorial type to their manager singleton.
+ /// You probably shouldn't be indexing this directly.
+ var/list/datum/tutorial_manager/tutorial_managers = list()
+
+ VAR_PRIVATE/list/datum/tutorial_manager/tutorial_managers_by_key = list()
+
+/datum/controller/subsystem/tutorials/Initialize()
+ init_tutorial_managers()
+ load_initial_tutorial_completions()
+ RegisterSignal(SSdcs, COMSIG_GLOB_CLIENT_CONNECT, PROC_REF(on_client_connect))
+
+ return SS_INIT_SUCCESS
+
+/// Will suggest the passed tutorial type to the user.
+/// Will check that they should actually see it, e.g. hasn't completed it yet, etc.
+/// Then, calls `/datum/tutorial/subtype/perform` with the extra arguments passed in.
+/datum/controller/subsystem/tutorials/proc/suggest_tutorial(mob/user, datum/tutorial/tutorial_type, ...)
+ var/datum/tutorial_manager/tutorial_manager = tutorial_managers[tutorial_type]
+ if (isnull(tutorial_manager))
+ CRASH("[tutorial_type] is not a valid tutorial type")
+
+ if (!tutorial_manager.should_run(user))
+ return
+
+ INVOKE_ASYNC(tutorial_manager, TYPE_PROC_REF(/datum/tutorial_manager, try_perform), user, args.Copy(3))
+
+/datum/controller/subsystem/tutorials/proc/init_tutorial_managers()
+ PRIVATE_PROC(TRUE)
+
+ for (var/datum/tutorial/tutorial_type as anything in subtypesof(/datum/tutorial))
+ var/datum/tutorial_manager/tutorial_manager = new /datum/tutorial_manager(tutorial_type)
+ tutorial_managers[tutorial_type] = tutorial_manager
+ tutorial_managers_by_key[tutorial_manager.get_key()] = tutorial_manager
+
+/datum/controller/subsystem/tutorials/proc/load_initial_tutorial_completions()
+ PRIVATE_PROC(TRUE)
+ set waitfor = FALSE // There's no reason to halt init for this
+
+ var/list/ckey_options = list()
+ var/list/ckeys = list()
+
+ for (var/client/client as anything in GLOB.clients)
+ var/ckey = client?.ckey
+ if (!ckey)
+ continue // client shenanigans, never trust
+
+ var/index = ckeys.len + 1
+ ckey_options += ":ckey[index]"
+ ckeys["ckey[index]"] = ckey
+
+ if (ckey_options.len == 0)
+ return
+
+ var/datum/db_query/select_all_query = SSdbcore.NewQuery(
+ "SELECT ckey, tutorial_key FROM [format_table_name("tutorial_completions")] WHERE ckey in ([ckey_options.Join(", ")])",
+ ckeys,
+ )
+
+ if (!select_all_query.Execute())
+ qdel(select_all_query)
+ return
+
+ while (select_all_query.NextRow())
+ var/ckey = select_all_query.item[1]
+ var/tutorial_key = select_all_query.item[2]
+
+ mark_ckey_completed_tutorial(ckey, tutorial_key)
+
+ qdel(select_all_query)
+
+/datum/controller/subsystem/tutorials/proc/on_client_connect(datum/source, client/client)
+ SIGNAL_HANDLER
+
+ var/ckey = client.ckey
+ if (!ckey)
+ return
+
+ INVOKE_ASYNC(src, PROC_REF(check_completed_tutorials_for_ckey), ckey)
+
+/datum/controller/subsystem/tutorials/proc/check_completed_tutorials_for_ckey(ckey)
+ if (!SSdbcore.IsConnected())
+ return
+
+ var/datum/db_query/select_tutorials_for_ckey = SSdbcore.NewQuery(
+ "SELECT tutorial_key FROM [format_table_name("tutorial_completions")] WHERE ckey = :ckey",
+ list("ckey" = ckey)
+ )
+
+ if (!select_tutorials_for_ckey.Execute())
+ return
+
+ while (select_tutorials_for_ckey.NextRow())
+ var/tutorial_key = select_tutorials_for_ckey.item[1]
+
+ mark_ckey_completed_tutorial(ckey, tutorial_key)
+
+ qdel(select_tutorials_for_ckey)
+
+/datum/controller/subsystem/tutorials/proc/mark_ckey_completed_tutorial(ckey, tutorial_key)
+ var/datum/tutorial_manager/tutorial_manager = tutorial_managers_by_key[tutorial_key]
+ if (isnull(tutorial_manager))
+ // Not necessarily a bug.
+ // Could be an outdated server or a removed tutorial.
+ return
+
+ tutorial_manager.mark_as_completed(ckey)
diff --git a/code/datums/components/twohanded.dm b/code/datums/components/twohanded.dm
index 2c0de250a2a..3c438b8c085 100644
--- a/code/datums/components/twohanded.dm
+++ b/code/datums/components/twohanded.dm
@@ -173,7 +173,7 @@
return // blocked wield from item
wielded = TRUE
ADD_TRAIT(parent, TRAIT_WIELDED, REF(src))
- RegisterSignal(user, COMSIG_MOB_SWAP_HANDS, PROC_REF(on_swap_hands))
+ RegisterSignal(user, COMSIG_MOB_SWAPPING_HANDS, PROC_REF(on_swapping_hands))
wield_callback?.Invoke(parent, user)
// update item stats and name
@@ -307,7 +307,7 @@
/**
* on_swap_hands Triggers on swapping hands, blocks swap if the other hand is busy
*/
-/datum/component/two_handed/proc/on_swap_hands(mob/user, obj/item/held_item)
+/datum/component/two_handed/proc/on_swapping_hands(mob/user, obj/item/held_item)
SIGNAL_HANDLER
if(!held_item)
diff --git a/code/game/objects/items.dm b/code/game/objects/items.dm
index b6747a6dcde..61368081a7b 100644
--- a/code/game/objects/items.dm
+++ b/code/game/objects/items.dm
@@ -678,6 +678,7 @@ GLOBAL_DATUM_INIT(fire_overlay, /mutable_appearance, mutable_appearance('icons/e
/obj/item/proc/pickup(mob/user)
SHOULD_CALL_PARENT(TRUE)
SEND_SIGNAL(src, COMSIG_ITEM_PICKUP, user)
+ SEND_SIGNAL(user, COMSIG_LIVING_PICKED_UP_ITEM, src)
item_flags |= IN_INVENTORY
/// called when "found" in pockets and storage items. Returns 1 if the search should end.
diff --git a/code/modules/mob/inventory.dm b/code/modules/mob/inventory.dm
index c493ed5f35e..1549ae2acb0 100644
--- a/code/modules/mob/inventory.dm
+++ b/code/modules/mob/inventory.dm
@@ -280,9 +280,15 @@
* If the item can be dropped, it will be forceMove()'d to the ground and the turf's Entered() will be called.
*/
/mob/proc/dropItemToGround(obj/item/I, force = FALSE, silent = FALSE, invdrop = TRUE)
+ if (isnull(I))
+ return TRUE
+
+ SEND_SIGNAL(src, COMSIG_MOB_DROPPING_ITEM)
. = doUnEquip(I, force, drop_location(), FALSE, invdrop = invdrop, silent = silent)
+
if(!. || !I) //ensure the item exists and that it was dropped properly.
return
+
if(!(I.item_flags & NO_PIXEL_RANDOM_DROP))
I.pixel_x = I.base_pixel_x + rand(-6, 6)
I.pixel_y = I.base_pixel_y + rand(-6, 6)
diff --git a/code/modules/mob/living/carbon/carbon.dm b/code/modules/mob/living/carbon/carbon.dm
index 6b23ef61ccd..e022be2d07f 100644
--- a/code/modules/mob/living/carbon/carbon.dm
+++ b/code/modules/mob/living/carbon/carbon.dm
@@ -30,7 +30,7 @@
QDEL_NULL(dna)
GLOB.carbon_list -= src
-/mob/living/carbon/swap_hand(held_index)
+/mob/living/carbon/perform_hand_swap(held_index)
. = ..()
if(!.)
return
diff --git a/code/modules/mob/living/silicon/robot/inventory.dm b/code/modules/mob/living/silicon/robot/inventory.dm
index f972cb0d885..e86ca257f12 100644
--- a/code/modules/mob/living/silicon/robot/inventory.dm
+++ b/code/modules/mob/living/silicon/robot/inventory.dm
@@ -397,7 +397,7 @@
if(slot_num > 4) // not >3 otherwise cycling with just one item on module 3 wouldn't work
slot_num = 1 //Wrap around.
-/mob/living/silicon/robot/swap_hand()
+/mob/living/silicon/robot/perform_hand_swap()
cycle_modules()
/mob/living/silicon/robot/can_hold_items(obj/item/I)
diff --git a/code/modules/mob/living/simple_animal/simple_animal.dm b/code/modules/mob/living/simple_animal/simple_animal.dm
index 3f206619222..eb1cf2c78a6 100644
--- a/code/modules/mob/living/simple_animal/simple_animal.dm
+++ b/code/modules/mob/living/simple_animal/simple_animal.dm
@@ -616,7 +616,7 @@
else
mode()
-/mob/living/simple_animal/swap_hand(hand_index)
+/mob/living/simple_animal/perform_hand_swap(hand_index)
. = ..()
if(!.)
return
diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm
index 1c39a8f1f14..24f2caa7b63 100644
--- a/code/modules/mob/mob.dm
+++ b/code/modules/mob/mob.dm
@@ -885,11 +885,23 @@
return data
-/mob/proc/swap_hand()
+/mob/proc/swap_hand(held_index)
+ SHOULD_NOT_OVERRIDE(TRUE) // Override perform_hand_swap instead
+
var/obj/item/held_item = get_active_held_item()
- if(SEND_SIGNAL(src, COMSIG_MOB_SWAP_HANDS, held_item) & COMPONENT_BLOCK_SWAP)
+ if(SEND_SIGNAL(src, COMSIG_MOB_SWAPPING_HANDS, held_item) & COMPONENT_BLOCK_SWAP)
to_chat(src, span_warning("Your other hand is too busy holding [held_item]."))
return FALSE
+
+ var/result = perform_hand_swap(held_index)
+ if (result)
+ SEND_SIGNAL(src, COMSIG_MOB_SWAP_HANDS)
+
+ return result
+
+/// Performs the actual ritual of swapping hands, such as setting the held index variables
+/mob/proc/perform_hand_swap(held_index)
+ PROTECTED_PROC(TRUE)
return TRUE
/mob/proc/activate_hand(selhand)
diff --git a/code/modules/tutorials/_tutorial.dm b/code/modules/tutorials/_tutorial.dm
new file mode 100644
index 00000000000..69f63738955
--- /dev/null
+++ b/code/modules/tutorials/_tutorial.dm
@@ -0,0 +1,257 @@
+/// The base for a contextual tutorial.
+/// In order to give a tutorial to someone, use `SStutorials.suggest_tutorial(user, /datum/tutorial/subtype)`
+/datum/tutorial
+ /// If set, any account who started playing before this date will not be given this tutorial.
+ /// Date is in YYYY-MM-DD format.
+ var/grandfather_date
+
+ /// The mob we are giving the tutorial to
+ VAR_PROTECTED/mob/user
+
+ VAR_PRIVATE/atom/movable/screen/tutorial_instruction/instruction_screen
+
+/datum/tutorial/New(mob/user)
+ src.user = user
+
+ RegisterSignal(user, COMSIG_PARENT_QDELETING, PROC_REF(destroy_self))
+ RegisterSignal(user.client, COMSIG_PARENT_QDELETING, PROC_REF(destroy_self))
+
+/datum/tutorial/Destroy(force, ...)
+ user.client?.screen -= instruction_screen
+ QDEL_NULL(instruction_screen)
+
+ user = null
+
+ return ..()
+
+/// Gets the [`/datum/tutorial_manager`] that owns this tutorial.
+/datum/tutorial/proc/manager()
+ RETURN_TYPE(/datum/tutorial_manager)
+ return SStutorials.tutorial_managers[type]
+
+/// The actual steps of the tutorial. Is given any excess arguments of suggest_tutorial.
+/// Must be overridden.
+/datum/tutorial/proc/perform()
+ SHOULD_CALL_PARENT(FALSE)
+ CRASH("[type] does not override perform()")
+
+/// Returns TRUE/FALSE if this tutorial should be given.
+/// If FALSE, does not mean it won't come back later.
+/datum/tutorial/proc/should_perform()
+ SHOULD_CALL_PARENT(FALSE)
+ return TRUE
+
+/// Called by the tutorial when the user has successfully completed it.
+/// Will mark it as completed in the datbaase and kick off destruction of the tutorial.
+/datum/tutorial/proc/complete()
+ SIGNAL_HANDLER
+ PROTECTED_PROC(TRUE)
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ manager().complete(user)
+ perform_base_completion_effects()
+
+/// As opposed to `complete()`, this merely hides the tutorial.
+/// This should be used when the user doesn't need the tutorial anymore, but didn't
+/// actually properly finish it.
+/datum/tutorial/proc/dismiss()
+ SIGNAL_HANDLER
+ PROTECTED_PROC(TRUE)
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ manager().dismiss(user)
+ perform_base_completion_effects()
+
+#define INSTRUCTION_SCREEN_DELAY (1 SECONDS)
+
+/datum/tutorial/proc/perform_base_completion_effects()
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ var/delay = perform_completion_effects_with_delay()
+
+ if (!isnull(instruction_screen))
+ animate(instruction_screen, time = INSTRUCTION_SCREEN_DELAY, alpha = 0, easing = SINE_EASING)
+ delay += INSTRUCTION_SCREEN_DELAY
+
+ QDEL_IN(src, delay)
+
+/// Called when the tutorial is being hidden, but before it is deleted.
+/// You should unregister signals and fade out any of your creations in here.
+/// Returns how long extra to delay the deletion.
+/datum/tutorial/proc/perform_completion_effects_with_delay()
+ SHOULD_CALL_PARENT(FALSE)
+ PROTECTED_PROC(TRUE)
+
+ return 0
+
+#undef INSTRUCTION_SCREEN_DELAY
+
+/datum/tutorial/proc/destroy_self()
+ SIGNAL_HANDLER
+ PRIVATE_PROC(TRUE)
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ manager().dismiss(user)
+ qdel(src)
+
+/// Shows a large piece of text on the user's screen with the given message.
+/// If a message already exists, will fade it out and replace it.
+/datum/tutorial/proc/show_instruction(message)
+ PROTECTED_PROC(TRUE)
+
+ if (isnull(instruction_screen))
+ instruction_screen = new(null, message, user.client)
+ user.client?.screen += instruction_screen
+ else
+ instruction_screen.change_message(message)
+
+/// Given a keybind and a message, will replace %KEY% in `message` with the first keybind they have.
+/// As a fallback, will return the third parameter, `message_without_keybinds`, if none are set.
+/datum/tutorial/proc/keybinding_message(datum/keybinding/keybinding_type, message, message_without_keybinds)
+ PROTECTED_PROC(TRUE)
+
+ var/list/keybinds = user.client?.prefs.key_bindings[initial(keybinding_type.name)]
+ return keybinds?.len > 0 ? replacetext(message, "%KEY%", "[keybinds[1]]") : message_without_keybinds
+
+/// Creates a UI element with the given `icon_state`, starts it at `initial_screen_loc`, and animates it to `target_screen_loc`.
+/// Waits `animate_start_time` before moving.
+/datum/tutorial/proc/animate_ui_element(icon_state, initial_screen_loc, target_screen_loc, animate_start_time)
+ PROTECTED_PROC(TRUE)
+
+ var/atom/movable/screen/preview = new
+ preview.icon = ui_style2icon(user.client?.prefs.read_preference(/datum/preference/choiced/ui_style) || GLOB.available_ui_styles[1])
+ preview.icon_state = icon_state
+ preview.mouse_opacity = MOUSE_OPACITY_TRANSPARENT
+ preview.screen_loc = "1,1"
+
+ var/view = user.client?.view
+
+ var/list/origin_offsets = screen_loc_to_offset(initial_screen_loc, view)
+
+ // A little offset to the right
+ var/matrix/origin_transform = TRANSLATE_MATRIX(origin_offsets[1] - world.icon_size * 0.5, origin_offsets[2] - world.icon_size * 1.5)
+
+ var/list/target_offsets = screen_loc_to_offset(target_screen_loc, view)
+ // `- world.icon_Size * 0.5` to patch over a likely bug in screen_loc_to_offset with CENTER, needs more looking at
+ var/matrix/animate_to_transform = TRANSLATE_MATRIX(target_offsets[1] - world.icon_size * 1.5, target_offsets[2] - world.icon_size)
+
+ preview.transform = origin_transform
+
+ preview.alpha = 0
+ animate(preview, time = animate_start_time, alpha = 255, easing = CUBIC_EASING)
+ animate(1.4 SECONDS)
+ animate(transform = animate_to_transform, time = 2 SECONDS, easing = SINE_EASING | EASE_IN)
+ animate(alpha = 0, time = 2.4 SECONDS, easing = CUBIC_EASING | EASE_IN, flags = ANIMATION_PARALLEL)
+
+ user.client?.screen += preview
+
+ return preview
+
+/// A singleton that manages when to create tutorials of a specific tutorial type.
+/datum/tutorial_manager
+ VAR_PRIVATE/datum/tutorial/tutorial_type
+
+ /// ckeys that we know have finished the tutorial
+ VAR_PRIVATE/list/finished_ckeys = list()
+
+ /// ckeys that have performed the tutorial, but have not completed it.
+ /// Doesn't mean that they can still see the tutorial, might have meant the tutorial was dismissed
+ /// without being completed, such as during a log out.
+ VAR_PRIVATE/list/performing_ckeys = list()
+
+/datum/tutorial_manager/New(tutorial_type)
+ ASSERT(ispath(tutorial_type, /datum/tutorial))
+ src.tutorial_type = tutorial_type
+
+/datum/tutorial_manager/Destroy(force, ...)
+ if (!force)
+ stack_trace("Something is trying to destroy [type], which is a singleton")
+ return QDEL_HINT_LETMELIVE
+ return ..()
+
+/// Checks if we should perform the tutorial for the given user, and performs if so.
+/// Use `SStutorials.suggest_tutorial` instead of calling this directly.
+/datum/tutorial_manager/proc/try_perform(mob/user, list/arguments)
+ var/datum/tutorial/tutorial = new tutorial_type(user)
+ if (!tutorial.should_perform(user))
+ qdel(tutorial)
+ return
+
+ performing_ckeys[user.ckey] = TRUE
+
+ tutorial.perform(arglist(arguments))
+
+/// Checks if the user should be given this tutorial
+/datum/tutorial_manager/proc/should_run(mob/user)
+ var/ckey = user.ckey
+
+ if (isnull(ckey))
+ return FALSE
+
+ if (ckey in finished_ckeys)
+ return FALSE
+
+ if (ckey in performing_ckeys)
+ return FALSE
+
+ if (!SSdbcore.IsConnected())
+ return CONFIG_GET(flag/give_tutorials_without_db)
+
+ var/player_join_date = user.client?.player_join_date
+ if (isnull(player_join_date))
+ return FALSE
+
+ // This works because ISO-8601 is cool
+ var/grandfather_date = initial(tutorial_type.grandfather_date)
+ if (!isnull(grandfather_date) && player_join_date < grandfather_date)
+ return FALSE
+
+ return TRUE
+
+/// Marks the tutorial as completed.
+/// Call `/datum/tutorial/proc/complete()` instead.
+/datum/tutorial_manager/proc/complete(mob/user)
+ set waitfor = FALSE
+
+ ASSERT(!isnull(user.ckey))
+
+ finished_ckeys[user.ckey] = TRUE
+ performing_ckeys -= user.ckey
+
+ SSblackbox.record_feedback("tally", "tutorial_completed", 1, "[tutorial_type]")
+ log_game("[key_name(user)] completed the [tutorial_type] tutorial.")
+
+ if (SSdbcore.IsConnected())
+ INVOKE_ASYNC(src, PROC_REF(log_completion_to_database), user.ckey)
+
+/datum/tutorial_manager/proc/log_completion_to_database(ckey)
+ PRIVATE_PROC(TRUE)
+
+ var/datum/db_query/insert_tutorial_query = SSdbcore.NewQuery(
+ "INSERT INTO [format_table_name("tutorial_completions")] (ckey, tutorial_key) VALUES (:ckey, :tutorial_key) ON DUPLICATE KEY UPDATE tutorial_key = tutorial_key",
+ list(
+ "ckey" = ckey,
+ "tutorial_key" = get_key(),
+ )
+ )
+
+ insert_tutorial_query.warn_execute()
+
+ qdel(insert_tutorial_query)
+
+/// Dismisses the tutorial, not marking it as completed in the database.
+/// Call `/datum/tutorial/proc/dismiss()` instead.
+/datum/tutorial_manager/proc/dismiss(mob/user)
+ performing_ckeys -= user.ckey
+
+/// Given a ckey, will mark them as being completed without affecting the database.
+/// Call `/datum/tutorial/proc/complete()` instead.
+/datum/tutorial_manager/proc/mark_as_completed(ckey)
+ finished_ckeys[ckey] = TRUE
+ performing_ckeys -= ckey
+
+/// Gives the key that will be saved in the database.
+/// Must be 64 characters or less.
+/datum/tutorial_manager/proc/get_key()
+ SHOULD_BE_PURE(TRUE)
+ return copytext("[tutorial_type]", length("[/datum/tutorial]") + 2)
diff --git a/code/modules/tutorials/tutorial_instruction.dm b/code/modules/tutorials/tutorial_instruction.dm
new file mode 100644
index 00000000000..488b3558eef
--- /dev/null
+++ b/code/modules/tutorials/tutorial_instruction.dm
@@ -0,0 +1,53 @@
+/atom/movable/screen/tutorial_instruction
+ icon = 'icons/effects/alphacolors.dmi'
+ icon_state = "white"
+ color = COLOR_NEARLY_ALL_BLACK
+ alpha = 0
+ screen_loc = "TOP-2,CENTER"
+ layer = TUTORIAL_INSTRUCTIONS_LAYER
+ mouse_opacity = MOUSE_OPACITY_TRANSPARENT
+
+ var/client/client
+ var/atom/movable/screen/tutorial_instruction_text/instruction_text
+
+/atom/movable/screen/tutorial_instruction/Initialize(mapload, message, client/client)
+ . = ..()
+
+ transform = transform.Scale(36, 2.5)
+
+ src.client = client
+ animate(src, alpha = 245, time = 0.8 SECONDS, easing = SINE_EASING)
+
+ instruction_text = new(src, message, client)
+ vis_contents += instruction_text
+
+/atom/movable/screen/tutorial_instruction/Destroy()
+ client = null
+ QDEL_NULL(instruction_text)
+
+ return ..()
+
+/atom/movable/screen/tutorial_instruction/proc/change_message(message)
+ instruction_text.change_message(message)
+
+/atom/movable/screen/tutorial_instruction_text
+ maptext_height = 480
+ maptext_y = -2
+ mouse_opacity = MOUSE_OPACITY_TRANSPARENT
+ layer = TUTORIAL_INSTRUCTIONS_LAYER
+
+/atom/movable/screen/tutorial_instruction_text/Initialize(mapload, message, client/client)
+ . = ..()
+
+ var/view = client?.view_size.getView()
+ maptext_width = view ? view_to_pixels(view)[1] : 480
+ pixel_x = (maptext_width - world.icon_size) * -0.5
+
+ change_message(message)
+
+/atom/movable/screen/tutorial_instruction_text/proc/change_message(message)
+ // We don't use MAPTEXT macro here because it doesn't handle big text
+ message = "[message]"
+
+ animate(src, alpha = 0, time = (maptext ? 0.5 SECONDS : 0), easing = SINE_EASING)
+ animate(alpha = 255, time = 0.5 SECONDS, maptext = message)
diff --git a/code/modules/tutorials/tutorials/drop.dm b/code/modules/tutorials/tutorials/drop.dm
new file mode 100644
index 00000000000..de692edab43
--- /dev/null
+++ b/code/modules/tutorials/tutorials/drop.dm
@@ -0,0 +1,109 @@
+#define TIME_TO_START_MOVING_DROP_ICON (0.5 SECONDS)
+
+#define STAGE_DROP_ITEM "STAGE_DROP_ITEM"
+#define STAGE_PICK_SOMETHING_UP "STAGE_PICK_SOMETHING_UP"
+
+/// Tutorial for showing how to drop items.
+/// Fired when clicking on an item with another item with a filled inactive hand.
+/datum/tutorial/drop
+ grandfather_date = "2023-01-07"
+
+ var/stage = STAGE_DROP_ITEM
+ var/atom/movable/screen/drop_preview
+ var/obj/last_held_item
+
+/datum/tutorial/drop/Destroy(force, ...)
+ last_held_item = null
+ user.client?.screen -= drop_preview
+ QDEL_NULL(drop_preview)
+ return ..()
+
+/datum/tutorial/drop/perform(list/params)
+ create_drop_preview(params[SCREEN_LOC])
+ addtimer(CALLBACK(src, PROC_REF(show_instructions)), TIME_TO_START_MOVING_DROP_ICON)
+
+ RegisterSignal(user, COMSIG_MOB_DROPPING_ITEM, PROC_REF(on_dropped_item))
+ RegisterSignal(user, COMSIG_MOB_SWAP_HANDS, PROC_REF(on_swap_hands))
+ RegisterSignal(user, COMSIG_LIVING_PICKED_UP_ITEM, PROC_REF(on_pick_up_item))
+
+ update_held_item()
+
+/datum/tutorial/drop/perform_completion_effects_with_delay()
+ UnregisterSignal(user, list(COMSIG_MOB_DROPPING_ITEM, COMSIG_MOB_SWAP_HANDS, COMSIG_LIVING_PICKED_UP_ITEM))
+ if (!isnull(last_held_item))
+ UnregisterSignal(last_held_item, COMSIG_MOVABLE_MOVED)
+
+ return 0
+
+/datum/tutorial/drop/proc/create_drop_preview(initial_screen_loc)
+ drop_preview = animate_ui_element(
+ "act_drop",
+ initial_screen_loc,
+ ui_drop_throw,
+ TIME_TO_START_MOVING_DROP_ICON,
+ )
+
+/datum/tutorial/drop/proc/show_instructions()
+ if (QDELETED(src))
+ return
+
+ switch (stage)
+ if (STAGE_DROP_ITEM)
+ show_instruction(keybinding_message(
+ /datum/keybinding/mob/drop_item,
+ "Press '%KEY%' to drop your current item",
+ "Click 'DROP' to drop your current item",
+ ))
+ if (STAGE_PICK_SOMETHING_UP)
+ show_instruction("Pick something up!")
+
+/datum/tutorial/drop/proc/on_swap_hands()
+ SIGNAL_HANDLER
+
+ if (isnull(user.get_active_held_item()))
+ if (stage != STAGE_PICK_SOMETHING_UP)
+ stage = STAGE_PICK_SOMETHING_UP
+ show_instructions()
+ else if (stage == STAGE_PICK_SOMETHING_UP)
+ stage = STAGE_DROP_ITEM
+ show_instructions()
+
+ update_held_item()
+
+/datum/tutorial/drop/proc/on_dropped_item()
+ SIGNAL_HANDLER
+
+ stage = STAGE_PICK_SOMETHING_UP
+ show_instructions()
+
+/datum/tutorial/drop/proc/on_pick_up_item()
+ SIGNAL_HANDLER
+
+ if (stage != STAGE_PICK_SOMETHING_UP)
+ dismiss()
+ return
+
+ complete()
+
+// Exists so that if we, say, place the item on a table, we don't count that as completion
+/datum/tutorial/drop/proc/update_held_item()
+ if (!isnull(last_held_item))
+ UnregisterSignal(last_held_item, COMSIG_MOVABLE_MOVED)
+
+ last_held_item = user.get_active_held_item()
+ if (isnull(last_held_item))
+ return
+
+ RegisterSignal(last_held_item, COMSIG_MOVABLE_MOVED, PROC_REF(on_held_item_moved))
+
+/datum/tutorial/drop/proc/on_held_item_moved()
+ SIGNAL_HANDLER
+
+ if (stage == STAGE_PICK_SOMETHING_UP)
+ return
+
+ dismiss()
+
+#undef STAGE_DROP_ITEM
+#undef STAGE_PICK_SOMETHING_UP
+#undef TIME_TO_START_MOVING_DROP_ICON
diff --git a/code/modules/tutorials/tutorials/switch_hands.dm b/code/modules/tutorials/tutorials/switch_hands.dm
new file mode 100644
index 00000000000..bf27a9e9d83
--- /dev/null
+++ b/code/modules/tutorials/tutorials/switch_hands.dm
@@ -0,0 +1,86 @@
+#define TIME_TO_START_MOVING_HAND_ICON (0.5 SECONDS)
+
+#define STAGE_SHOULD_SWAP_HAND "STAGE_SHOULD_SWAP_HAND"
+#define STAGE_PICK_UP_ITEM "STAGE_PICK_UP_ITEM"
+
+/// Tutorial for showing how to switch hands.
+/// Fired when clicking on an item with another item with an empty inactive hand.
+/datum/tutorial/switch_hands
+ grandfather_date = "2023-01-07"
+
+ var/stage = STAGE_SHOULD_SWAP_HAND
+ var/atom/movable/screen/hand_preview
+
+ // So that they don't just drop the item
+ var/hand_to_watch
+
+/datum/tutorial/switch_hands/New(mob/user)
+ . = ..()
+
+ hand_to_watch = (user.active_hand_index % user.held_items.len) + 1
+
+/datum/tutorial/switch_hands/Destroy(force, ...)
+ user.client?.screen -= hand_preview
+ QDEL_NULL(hand_preview)
+
+ return ..()
+
+/datum/tutorial/switch_hands/perform(list/params)
+ create_hand_preview(params[SCREEN_LOC])
+ addtimer(CALLBACK(src, PROC_REF(show_instructions)), TIME_TO_START_MOVING_HAND_ICON)
+
+ RegisterSignal(user, COMSIG_MOB_SWAP_HANDS, PROC_REF(on_swap_hands))
+ RegisterSignal(user, COMSIG_LIVING_PICKED_UP_ITEM, PROC_REF(on_pick_up_item))
+
+/datum/tutorial/switch_hands/perform_completion_effects_with_delay()
+ UnregisterSignal(user, list(COMSIG_MOB_SWAP_HANDS, COMSIG_LIVING_PICKED_UP_ITEM))
+ return 0
+
+/datum/tutorial/switch_hands/proc/create_hand_preview(initial_screen_loc)
+ hand_preview = animate_ui_element(
+ "hand_[hand_to_watch % 2 == 0 ? "r" : "l"]",
+ initial_screen_loc,
+ ui_hand_position(hand_to_watch),
+ TIME_TO_START_MOVING_HAND_ICON,
+ )
+
+/datum/tutorial/switch_hands/proc/show_instructions()
+ if (QDELETED(src))
+ return
+
+ switch (stage)
+ if (STAGE_SHOULD_SWAP_HAND)
+ var/hand_name = hand_to_watch % 2 == 0 ? "right" : "left"
+ show_instruction(keybinding_message(
+ /datum/keybinding/mob/swap_hands,
+ "Press '%KEY%' to use your [hand_name] hand",
+ "Click 'SWAP' to use your [hand_name] hand",
+ ))
+ if (STAGE_PICK_UP_ITEM)
+ show_instruction("Pick something up!")
+
+/datum/tutorial/switch_hands/proc/on_swap_hands()
+ SIGNAL_HANDLER
+
+ if (isnull(user.get_active_held_item()))
+ stage = STAGE_PICK_UP_ITEM
+ show_instructions()
+ else if (isnull(user.get_inactive_held_item()))
+ stage = STAGE_SHOULD_SWAP_HAND
+ show_instructions()
+ else
+ // You somehow got an item in both hands during the tutorial without switching hands.
+ // Good job I guess?
+ complete()
+
+/datum/tutorial/switch_hands/proc/on_pick_up_item()
+ SIGNAL_HANDLER
+
+ if (user.active_hand_index != hand_to_watch)
+ return
+
+ complete()
+
+#undef STAGE_PICK_UP_ITEM
+#undef STAGE_SHOULD_SWAP_HAND
+#undef TIME_TO_START_MOVING_HAND_ICON
diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm
index c9e9b8e7459..5760a886e4a 100644
--- a/code/modules/unit_tests/_unit_tests.dm
+++ b/code/modules/unit_tests/_unit_tests.dm
@@ -208,6 +208,7 @@
#include "tgui_create_message.dm"
#include "timer_sanity.dm"
#include "traitor.dm"
+#include "tutorial_sanity.dm"
#include "unit_test.dm"
#include "verify_config_tags.dm"
#include "verify_emoji_names.dm"
diff --git a/code/modules/unit_tests/tutorial_sanity.dm b/code/modules/unit_tests/tutorial_sanity.dm
new file mode 100644
index 00000000000..3c4ae37c9c0
--- /dev/null
+++ b/code/modules/unit_tests/tutorial_sanity.dm
@@ -0,0 +1,19 @@
+/// Verifies that every tutorial has properly set variables
+/datum/unit_test/tutorial_sanity
+
+/datum/unit_test/tutorial_sanity/Run()
+ var/regex/regex_valid_date = regex(@"\d{4}-\d{2}-\d{2}")
+ var/list/keys = list()
+
+ for (var/datum/tutorial/tutorial_type as anything in SStutorials.tutorial_managers)
+ var/datum/tutorial_manager/tutorial_manager = SStutorials.tutorial_managers[tutorial_type]
+
+ var/grandfather_date = initial(tutorial_type.grandfather_date)
+ if (!isnull(grandfather_date))
+ TEST_ASSERT(regex_valid_date.Find(grandfather_date), "[tutorial_type] has an invalid grandfather_date ([grandfather_date])")
+
+ var/key = tutorial_manager.get_key()
+ TEST_ASSERT(!(key in keys), "[key] shows up twice")
+ TEST_ASSERT(length(key) < 64, "[key] is more than 64 characters, it won't fit in the SQL table.")
+
+ TEST_ASSERT_EQUAL(SStutorials.tutorial_managers.len, length(subtypesof(/datum/tutorial)), "Expected tutorial_managers to have one of every tutorial")
diff --git a/config/game_options.txt b/config/game_options.txt
index fa59a2100b0..3cf2af6c919 100644
--- a/config/game_options.txt
+++ b/config/game_options.txt
@@ -556,3 +556,8 @@ MAXFINE 2000
## Comment if you wish to enable title music playing at the lobby screen. This flag is disabled by default to facilitate better code testing on local machines.
## Do keep in mind that this flag will not affect individual player's preferences: if they opt-out on your server, it will never play for them.
DISALLOW_TITLE_MUSIC
+
+## If enabled, then when the database is disabled, all players will get tutorials.
+## This is primarily useful for developing tutorials. If you have a proper DB setup, you
+## don't need (or want) this.
+#GIVE_TUTORIALS_WITHOUT_DB
diff --git a/interface/VCR_OSD_Mono.ttf b/interface/VCR_OSD_Mono.ttf
new file mode 100644
index 00000000000..dcca687a434
Binary files /dev/null and b/interface/VCR_OSD_Mono.ttf differ
diff --git a/interface/fonts.dm b/interface/fonts.dm
new file mode 100644
index 00000000000..0bfc9c728c7
--- /dev/null
+++ b/interface/fonts.dm
@@ -0,0 +1,10 @@
+/// A font datum, it exists to define a custom font to use in a span style later.
+/datum/font
+ /// Font name, just so people know what to put in their span style.
+ var/name
+ /// The font file we link to.
+ var/font_family
+
+/datum/font/vcr_osd_mono
+ name = "VCR OSD Mono"
+ font_family = 'interface/VCR_OSD_Mono.ttf'
diff --git a/interface/license.txt b/interface/license.txt
new file mode 100644
index 00000000000..5f74403c50a
--- /dev/null
+++ b/interface/license.txt
@@ -0,0 +1,2 @@
+VCR OSD Mono created by Riciery Leal/mrmanet. Website indicates 100% free, author confirms it's free for all to use.
+(https://www.dafont.com/font-comment.php?file=vcr_osd_mono)
diff --git a/tgstation.dme b/tgstation.dme
index 4f9415f70c9..660223dc6a5 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -664,6 +664,7 @@
#include "code\controllers\subsystem\timer.dm"
#include "code\controllers\subsystem\title.dm"
#include "code\controllers\subsystem\traitor.dm"
+#include "code\controllers\subsystem\tutorials.dm"
#include "code\controllers\subsystem\verb_manager.dm"
#include "code\controllers\subsystem\vis_overlays.dm"
#include "code\controllers\subsystem\vote.dm"
@@ -4792,6 +4793,10 @@
#include "code\modules\tgui_panel\telemetry.dm"
#include "code\modules\tgui_panel\tgui_panel.dm"
#include "code\modules\tooltip\tooltip.dm"
+#include "code\modules\tutorials\_tutorial.dm"
+#include "code\modules\tutorials\tutorial_instruction.dm"
+#include "code\modules\tutorials\tutorials\drop.dm"
+#include "code\modules\tutorials\tutorials\switch_hands.dm"
#include "code\modules\unit_tests\_unit_tests.dm"
#include "code\modules\uplink\uplink_devices.dm"
#include "code\modules\uplink\uplink_items.dm"
@@ -5022,6 +5027,7 @@
#include "code\modules\wiremod\shell\shell_items.dm"
#include "code\modules\zombie\items.dm"
#include "code\modules\zombie\organs.dm"
+#include "interface\fonts.dm"
#include "interface\interface.dm"
#include "interface\menu.dm"
#include "interface\stylesheet.dm"