diff --git a/code/__DEFINES/_flags.dm b/code/__DEFINES/_flags.dm
index 53b1514ed6a..e54b939404c 100644
--- a/code/__DEFINES/_flags.dm
+++ b/code/__DEFINES/_flags.dm
@@ -318,6 +318,8 @@ GLOBAL_LIST_INIT(bitflags, list(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 204
#define IGNORE_INCAPACITATED (1<<3)
/// Used to prevent important slowdowns from being abused by drugs like kronkaine
#define IGNORE_SLOWDOWNS (1<<4)
+/// Cancel the action if the user does another action (mainly via clicking)
+#define DO_AFTER_CHECK_NEXT_MOVE (1<<5)
// Spacevine-related flags
/// Is the spacevine / flower bud heat resistant
diff --git a/code/__DEFINES/antagonists.dm b/code/__DEFINES/antagonists.dm
index 60d1212540c..36225923fe0 100644
--- a/code/__DEFINES/antagonists.dm
+++ b/code/__DEFINES/antagonists.dm
@@ -422,6 +422,7 @@ GLOBAL_LIST_INIT(human_invader_antagonists, list(
#define ANTAG_GROUP_ARACHNIDS "Arachnid Infestation"
#define ANTAG_GROUP_ASHWALKERS "Ash Walkers"
#define ANTAG_GROUP_BIOHAZARDS "Biohazards"
+#define ANTAG_GROUP_BLOODY "Bloody Monstrosities"
#define ANTAG_GROUP_CLOWNOPS "Clown Operatives"
#define ANTAG_GROUP_ERT "Emergency Response Team"
#define ANTAG_GROUP_GLITCH "Digital Anomalies"
diff --git a/code/__DEFINES/atom_hud.dm b/code/__DEFINES/atom_hud.dm
index 7b65c539cd4..3c043eae400 100644
--- a/code/__DEFINES/atom_hud.dm
+++ b/code/__DEFINES/atom_hud.dm
@@ -46,8 +46,10 @@
#define DIAG_CAMERA_HUD "21"
/// Steady Hacked APC effect, visible only to Malf AIs
#define MALF_APC_HUD "22"
+/// Blood volume bar, visible to blood worms
+#define BLOOD_HUD "23"
/// Big Manipulator interaction point HUDs
-#define BIG_MANIP_HUD "23"
+#define BIG_MANIP_HUD "24"
//by default everything in the hud_list of an atom is an image
//a value in hud_list with one of these will change that behavior
@@ -65,6 +67,7 @@
#define DATA_HUD_AI_DETECT 8
#define DATA_HUD_FAN 9
#define DATA_HUD_MALF_APC 10
+#define DATA_HUD_BLOOD 11
/// cooldown for being shown the images for any particular data hud
#define ADD_HUD_TO_COOLDOWN 20
diff --git a/code/__DEFINES/blood.dm b/code/__DEFINES/blood.dm
index 92753667574..aacd75db56f 100644
--- a/code/__DEFINES/blood.dm
+++ b/code/__DEFINES/blood.dm
@@ -24,3 +24,10 @@
/// Mobs will drop everything
#define DROP_ALL_REMAINS (DROP_BRAIN | DROP_ORGANS | DROP_BODYPARTS | DROP_ITEMS)
+
+// Keys for indexing blood data lists. HIGHLY INCOMPLETE.
+/// Indexing a blood reagent data list with this returns how synthetic the blood is, used for blood worms to nerf common blood sources like monkeys.
+#define BLOOD_DATA_SYNTH_CONTENT "synth_content"
+
+/// Returns whether this mob always has synthetic blood. Used to cap growth for blood worms from easily accessible sources of blood.
+#define IS_BLOOD_ALWAYS_SYNTHETIC(mob) (!ishuman(mob) || HAS_TRAIT(mob, TRAIT_BORN_MONKEY) || HAS_TRAIT(mob, TRAIT_SPAWNED_MOB))
diff --git a/code/__DEFINES/blood_worm_defines.dm b/code/__DEFINES/blood_worm_defines.dm
new file mode 100644
index 00000000000..f0a5bed60d9
--- /dev/null
+++ b/code/__DEFINES/blood_worm_defines.dm
@@ -0,0 +1,12 @@
+#define BLOOD_WORM_BLOOD_TO_HEALTH (100 / BLOOD_VOLUME_NORMAL)
+#define BLOOD_WORM_HEALTH_TO_BLOOD (BLOOD_VOLUME_NORMAL / 100)
+
+/// The health percentage at which blood worms are forcibly kicked out from their hosts.
+#define BLOOD_WORM_EJECT_THRESHOLD 0.1
+
+/// How much faster blood worm hosts bleed. Required because blood worms, unlike people, don't die at ~60% blood, instead they leave hosts at 10% blood, and adults have a ton of blood.
+/// This means blood worms are ridiculously strong against one of their own weaknesses without this. Don't make this too high, because blood worms use blood to heal, attack and survive.
+#define BLOOD_WORM_BLEED_MOD 1.5
+
+/// Returns a blood worm from the mob if it's a blood worm or the host of one.
+#define FIND_BLOOD_WORM(mob) ((isbloodworm(mob) ? mob : null) || ((mob && HAS_TRAIT(mob, TRAIT_BLOOD_WORM_HOST)) ? (locate(/mob/living/basic/blood_worm) in mob) : null))
diff --git a/code/__DEFINES/dcs/signals/signals_blood_worm.dm b/code/__DEFINES/dcs/signals/signals_blood_worm.dm
new file mode 100644
index 00000000000..55ede86dbdf
--- /dev/null
+++ b/code/__DEFINES/dcs/signals/signals_blood_worm.dm
@@ -0,0 +1,4 @@
+/// From /mob/living/basic/blood_worm/proc/consume_blood() : (normal_blood_amount, synth_blood_amount, total_blood_amount)
+#define COMSIG_BLOOD_WORM_CONSUMED_BLOOD "blood_worm_consumed_blood"
+/// From /datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/transfer() : ()
+#define COMSIG_BLOOD_WORM_REPRODUCED "blood_worm_reproduced"
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 e6d71fefae7..4922c80f9d1 100644
--- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm
+++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm
@@ -370,6 +370,9 @@
/// From /mob/living/proc/mob_pickup() : (mob/living/user, obj/item/mob_holder/holder)
#define COMSIG_LIVING_SCOOPED_UP "living_scooped_up"
+/// From /mob/living/proc/update_blood_status(), sent when the return value of /mob/living/proc/can_have_blood() changes, but before the new blood status is applied : (had_blood, has_blood, old_blood_volume)
+#define COMSIG_LIVING_PRE_UPDATE_BLOOD_STATUS "living_pre_update_blood_status"
+
/// From /mob/living/proc/update_blood_status(), sent when the return value of /mob/living/proc/can_have_blood() changes : (had_blood, has_blood, old_blood_volume, new_blood_volume)
#define COMSIG_LIVING_UPDATE_BLOOD_STATUS "living_update_blood_status"
diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_simple.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_simple.dm
index cdbd0d3e432..e2f04dd8fe0 100644
--- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_simple.dm
+++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_simple.dm
@@ -5,7 +5,7 @@
#define COMSIG_SIMPLEMOB_TRANSFERPOTION "simplemob_transferpotion"
// /mob/living/simple_animal/hostile signals
-///before attackingtarget has happened, source is the attacker and target is the attacked
+///before attackingtarget has happened, source is the attacker and target is the attacked : (atom/target, is_adjacent, modifiers)
#define COMSIG_HOSTILE_PRE_ATTACKINGTARGET "hostile_pre_attackingtarget"
#define COMPONENT_HOSTILE_NO_ATTACK COMPONENT_CANCEL_ATTACK_CHAIN //cancel the attack, only works before attack happens
///after attackingtarget has happened, source is the attacker and target is the attacked, extra argument for if the attackingtarget was successful
diff --git a/code/__DEFINES/is_helpers.dm b/code/__DEFINES/is_helpers.dm
index c5e9d39690f..c4cffab911e 100644
--- a/code/__DEFINES/is_helpers.dm
+++ b/code/__DEFINES/is_helpers.dm
@@ -177,6 +177,8 @@ GLOBAL_LIST_INIT(turfs_pass_meteor, typecacheof(list(
#define isstargazer(A) (istype(A, /mob/living/basic/heretic_summon/star_gazer))
+#define isbloodworm(A) (istype(A, /mob/living/basic/blood_worm))
+
/// returns whether or not the atom is either a basic mob OR simple animal
#define isanimal_or_basicmob(A) (istype(A, /mob/living/simple_animal) || istype(A, /mob/living/basic))
diff --git a/code/__DEFINES/language.dm b/code/__DEFINES/language.dm
index 9189ae20b2e..937fc987aa6 100644
--- a/code/__DEFINES/language.dm
+++ b/code/__DEFINES/language.dm
@@ -40,6 +40,7 @@
#define LANGUAGE_BABEL "babel"
#define LANGUAGE_EMP "emp"
#define LANGUAGE_TONGUE "tongue"
+#define LANGUAGE_BLOOD_WORM "blood_worm"
// Language flags. Used in granting and removing languages.
/// This language can be spoken.
diff --git a/code/__DEFINES/living.dm b/code/__DEFINES/living.dm
index ac2cb64efae..0aa8bee1064 100644
--- a/code/__DEFINES/living.dm
+++ b/code/__DEFINES/living.dm
@@ -12,8 +12,9 @@
#define STOP_OVERLAY_UPDATE_BODY_PARTS (1<<2)
/// Nutrition changed last life tick, so we should bulk update this tick
#define QUEUE_NUTRITION_UPDATE (1<<3)
-/// Blood volume has changed since the last [proc/update_blood_effects] call
-#define QUEUE_BLOOD_UPDATE (1<<4)
+/// Blood volume or status has changed since the last [proc/update_blood_effects] call.
+/// Nowhere near guaranteed to happen only once per life tick, or at all.
+#define BLOOD_UPDATE_QUEUED (1<<4)
/// This mob can have blood, cached value of [proc/can_have_blood]
#define LIVING_CAN_HAVE_BLOOD (1<<5)
@@ -21,6 +22,8 @@
#define GET_LYING_ANGLE(mob) (UNLINT(mob.lying_angle))
/// Checks if the mob can have blood
#define CAN_HAVE_BLOOD(mob) (mob.living_flags & LIVING_CAN_HAVE_BLOOD)
+/// Queues a blood update for the next life tick for the mob
+#define QUEUE_BLOOD_UPDATE(mob) mob.living_flags |= BLOOD_UPDATE_QUEUED
// Used in living mob offset list for determining pixel offsets
#define PIXEL_W_OFFSET "w"
diff --git a/code/__DEFINES/logging.dm b/code/__DEFINES/logging.dm
index 53a82a57555..8b730ef4659 100644
--- a/code/__DEFINES/logging.dm
+++ b/code/__DEFINES/logging.dm
@@ -141,6 +141,7 @@
// Game categories
#define LOG_CATEGORY_GAME "game"
#define LOG_CATEGORY_GAME_ACCESS "game-access"
+#define LOG_CATEGORY_GAME_BLOOD_WORM "game-blood-worm"
#define LOG_CATEGORY_GAME_EMOTE "game-emote"
#define LOG_CATEGORY_GAME_INTERNET_REQUEST "game-internet-request"
#define LOG_CATEGORY_GAME_OOC "game-ooc"
diff --git a/code/__DEFINES/mobfactions.dm b/code/__DEFINES/mobfactions.dm
index ae3b506484a..04126755f9d 100644
--- a/code/__DEFINES/mobfactions.dm
+++ b/code/__DEFINES/mobfactions.dm
@@ -13,6 +13,8 @@
#define FACTION_ASHWALKER "ashwalker"
/// Bears of all varieties
#define FACTION_BEAR "bear"
+/// Blood worms
+#define FACTION_BLOOD_WORM "blood_worm"
/// Megafauna bosses of mining
#define FACTION_BOSS "boss"
/// CARPS
diff --git a/code/__DEFINES/role_preferences.dm b/code/__DEFINES/role_preferences.dm
index 004f990bcdf..12e2718caad 100644
--- a/code/__DEFINES/role_preferences.dm
+++ b/code/__DEFINES/role_preferences.dm
@@ -8,6 +8,7 @@
//then you MUST update the database!
// Roundstart roles
+#define ROLE_BLOOD_WORM "Blood Worm"
#define ROLE_BROTHER "Blood Brother"
#define ROLE_CHANGELING "Changeling"
#define ROLE_CULTIST "Cultist"
@@ -23,6 +24,7 @@
#define ROLE_ALIEN "Xenomorph"
#define ROLE_BLOB "Blob"
#define ROLE_BLOB_INFECTION "Blob Infection"
+#define ROLE_BLOOD_WORM_INFESTATION "Blood Worm Infestation"
#define ROLE_CHANGELING_MIDROUND "Changeling (Midround)"
#define ROLE_FUGITIVE "Fugitive"
#define ROLE_LONE_OPERATIVE "Lone Operative"
diff --git a/code/__DEFINES/sound.dm b/code/__DEFINES/sound.dm
index 23fc91dca3b..1e6a51e23b2 100644
--- a/code/__DEFINES/sound.dm
+++ b/code/__DEFINES/sound.dm
@@ -320,6 +320,7 @@ GLOBAL_LIST_EMPTY(sfx_datum_by_key)
#define SFX_HARD_HAT_PICKUP "hard_hat_pickup"
#define SFX_HARD_HAT_DROP "hard_hat_drop"
#define SFX_HARD_HAT_EQUIP "hard_hat_equip"
+#define SFX_ALIEN_SPIT_ACID "alien_split_acid"
// Standard is 44.1khz
#define MIN_EMOTE_PITCH 40000
diff --git a/code/__DEFINES/traits/_traits.dm b/code/__DEFINES/traits/_traits.dm
index 0b1231c081f..c873152d2d5 100644
--- a/code/__DEFINES/traits/_traits.dm
+++ b/code/__DEFINES/traits/_traits.dm
@@ -104,7 +104,7 @@
}; \
if (!length(_L[_T])) { \
_L -= _T; \
- SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(_T)); \
+ SEND_SIGNAL(target, SIGNAL_REMOVETRAIT(_T), _T); \
}; \
};\
if (!length(_L)) { \
diff --git a/code/__DEFINES/traits/declarations.dm b/code/__DEFINES/traits/declarations.dm
index 3b8503f9706..38f63b681b2 100644
--- a/code/__DEFINES/traits/declarations.dm
+++ b/code/__DEFINES/traits/declarations.dm
@@ -262,6 +262,8 @@ Remember to update _globalvars/traits.dm if you're adding/removing/renaming trai
#define TRAIT_EASYBLEED "easybleed"
/// Mob recovers from addictions at an accelerated rate
#define TRAIT_ADDICTIONRESILIENT "addiction_resilient"
+/// Mob is immune to the effects of addictions
+#define TRAIT_NO_WITHDRAWALS "no_withdrawals"
#define TRAIT_TOXINLOVER "toxinlover"
/// Doesn't get overlays from being in critical.
#define TRAIT_NOCRITOVERLAY "no_crit_overlay"
@@ -444,6 +446,7 @@ Remember to update _globalvars/traits.dm if you're adding/removing/renaming trai
#define TRAIT_MEDICAL_HUD_SENSOR_ONLY "med_hud_lesser"
#define TRAIT_SECURITY_HUD "sec_hud"
#define TRAIT_SECURITY_HUD_ID_ONLY "sec_hud_lesser"
+#define TRAIT_BLOOD_HUD "blood_hud"
#define TRAIT_ABDUCTOR_HUD "abductor_hud"
/// Stop the user from seeing the sechud. Only works for trait handled sechuds.
#define TRAIT_BLOCK_SECHUD "block_sechud"
@@ -1616,6 +1619,18 @@ Remember to update _globalvars/traits.dm if you're adding/removing/renaming trai
/// Mob is artificially spawned rather than being created through more natural means - applied to monkey cubes and such
#define TRAIT_SPAWNED_MOB "spawned_mob"
+/// Mob is the host of a living blood worm.
+#define TRAIT_BLOOD_WORM_HOST "blood_worm_host"
+
+/// Mob just doesn't fucking care. No mood and no sanity.
+#define TRAIT_APATHETIC "apathetic"
+
+/// Mob can't be inflicted with the split personality trauma.
+#define TRAIT_NO_SPLIT_PERSONALITY "no_split_personality"
+
+/// Mob is shapeshifted, e.g. via a polymorph belt.
+#define TRAIT_SHAPESHIFTED "shapeshifted"
+
/// Turf is one that ai mobs will generally avoid pathing through
/// Doesn't need to be applied to any turfs that override can_cross_safely
#define TRAIT_AI_AVOID_TURF "warning_turf"
diff --git a/code/__DEFINES/traits/sources.dm b/code/__DEFINES/traits/sources.dm
index 8f40c3c8b8e..8cbe3049d0f 100644
--- a/code/__DEFINES/traits/sources.dm
+++ b/code/__DEFINES/traits/sources.dm
@@ -330,6 +330,12 @@
/// Permanent trait from an overdose effect
#define OVERDOSE_TRAIT "overdose"
+/// Trait from a blood worm being in a host
+#define BLOOD_WORM_HOST_TRAIT "blood_worm_host_trait"
+
+/// Trait from being under a door via [/datum/component/slide_under_doors]
+#define UNDER_DOOR_TRAIT "under_door_trait"
+
/// Trait gained from bioware surgeries
#define BIOWARE_TRAIT "bioware"
diff --git a/code/__HELPERS/level_traits.dm b/code/__HELPERS/_level_traits.dm
similarity index 100%
rename from code/__HELPERS/level_traits.dm
rename to code/__HELPERS/_level_traits.dm
diff --git a/code/__HELPERS/events.dm b/code/__HELPERS/events.dm
index 58d91c8caa8..ad937029026 100644
--- a/code/__HELPERS/events.dm
+++ b/code/__HELPERS/events.dm
@@ -47,6 +47,25 @@
return pick(possible_spawns)
+/// Finds us all suitable vent spawn locations on the station.
+/proc/find_vent_spawns()
+ var/list/vents = list()
+ var/list/vent_pumps = SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/atmospherics/components/unary/vent_pump)
+ for(var/obj/machinery/atmospherics/components/unary/vent_pump/temp_vent as anything in vent_pumps)
+ if(QDELETED(temp_vent))
+ continue
+ if(!is_station_level(temp_vent.loc.z) || temp_vent.welded)
+ continue
+ var/datum/pipeline/temp_vent_parent = temp_vent.parents[1]
+ if(!temp_vent_parent)
+ continue
+ // Stops antagonists getting stuck in small networks.
+ // See: Security, Virology
+ if(length(temp_vent_parent.other_atmos_machines) <= 20)
+ continue
+ vents += temp_vent
+ return vents
+
/proc/force_event(event_typepath, cause)
var/datum/round_event_control/our_event = locate(event_typepath) in SSevents.control
if(!our_event)
diff --git a/code/__HELPERS/logging/antagonists.dm b/code/__HELPERS/logging/antagonists.dm
index 5df39c69ade..514c4a3acfa 100644
--- a/code/__HELPERS/logging/antagonists.dm
+++ b/code/__HELPERS/logging/antagonists.dm
@@ -25,3 +25,7 @@
/// Logs bounties completed by spies and their rewards
/proc/log_spy(text, list/data)
logger.Log(LOG_CATEGORY_UPLINK_SPY, text, data)
+
+/// Logs blood worm events like entering/leaving a host.
+/proc/log_blood_worm(text, list/data)
+ logger.Log(LOG_CATEGORY_GAME_BLOOD_WORM, text, data)
diff --git a/code/__HELPERS/mobs.dm b/code/__HELPERS/mobs.dm
index a93f3d7402a..4a9d2e205e2 100644
--- a/code/__HELPERS/mobs.dm
+++ b/code/__HELPERS/mobs.dm
@@ -267,6 +267,7 @@ GLOBAL_LIST_INIT(skin_tone_names, list(
|| (!(timed_action_flags & IGNORE_USER_LOC_CHANGE) && !drifting && user.loc != user_loc) \
|| (!(timed_action_flags & IGNORE_HELD_ITEM) && user.get_active_held_item() != holding) \
|| (!(timed_action_flags & IGNORE_INCAPACITATED) && HAS_TRAIT(user, TRAIT_INCAPACITATED)) \
+ || ((timed_action_flags & DO_AFTER_CHECK_NEXT_MOVE) && world.time < user.next_move) \
|| (extra_checks && !extra_checks.Invoke()))
. = FALSE
break
diff --git a/code/_globalvars/blood_worms.dm b/code/_globalvars/blood_worms.dm
new file mode 100644
index 00000000000..e3ffde57443
--- /dev/null
+++ b/code/_globalvars/blood_worms.dm
@@ -0,0 +1 @@
+GLOBAL_DATUM(blood_worm_team, /datum/team/blood_worm)
diff --git a/code/_globalvars/lists/poll_ignore.dm b/code/_globalvars/lists/poll_ignore.dm
index 2a88c05de6a..5a16fa6bff1 100644
--- a/code/_globalvars/lists/poll_ignore.dm
+++ b/code/_globalvars/lists/poll_ignore.dm
@@ -5,6 +5,7 @@
#define POLL_IGNORE_ASH_SPIRIT "ash_spirit"
#define POLL_IGNORE_ASHWALKER "ashwalker"
#define POLL_IGNORE_BLOB "blob"
+#define POLL_IGNORE_BLOOD_WORM "blood_worm"
#define POLL_IGNORE_BOTS "bots"
#define POLL_IGNORE_CARGORILLA "cargorilla"
#define POLL_IGNORE_CONSTRUCT "construct"
diff --git a/code/_globalvars/traits/_traits.dm b/code/_globalvars/traits/_traits.dm
index a975dc57457..3124abd35d7 100644
--- a/code/_globalvars/traits/_traits.dm
+++ b/code/_globalvars/traits/_traits.dm
@@ -179,6 +179,7 @@ GLOBAL_LIST_INIT(traits_by_type, list(
"TRAIT_ANTIMAGIC" = TRAIT_ANTIMAGIC,
"TRAIT_ANTIMAGIC_NO_SELFBLOCK" = TRAIT_ANTIMAGIC_NO_SELFBLOCK,
"TRAIT_ANXIOUS" = TRAIT_ANXIOUS,
+ "TRAIT_APATHETIC" = TRAIT_APATHETIC,
"TRAIT_ATTEMPTING_CHRISTENING" = TRAIT_ATTEMPTING_CHRISTENING,
"TRAIT_BADDNA" = TRAIT_BADDNA,
"TRAIT_BADTOUCH" = TRAIT_BADTOUCH,
@@ -197,6 +198,8 @@ GLOBAL_LIST_INIT(traits_by_type, list(
"TRAIT_BLOODY_MESS" = TRAIT_BLOODY_MESS,
"TRAIT_BLOOD_CLANS" = TRAIT_BLOOD_CLANS,
"TRAIT_BLOOD_FOUNTAIN" = TRAIT_BLOOD_FOUNTAIN,
+ "TRAIT_BLOOD_HUD" = TRAIT_BLOOD_HUD,
+ "TRAIT_BLOOD_WORM_HOST" = TRAIT_BLOOD_WORM_HOST,
"TRAIT_BOMBIMMUNE" = TRAIT_BOMBIMMUNE,
"TRAIT_BONSAI" = TRAIT_BONSAI,
"TRAIT_BOOZE_SLIDER" = TRAIT_BOOZE_SLIDER,
@@ -453,12 +456,14 @@ GLOBAL_LIST_INIT(traits_by_type, list(
"TRAIT_NO_SLIP_SLIDE" = TRAIT_NO_SLIP_SLIDE,
"TRAIT_NO_SLIP_WATER" = TRAIT_NO_SLIP_WATER,
"TRAIT_NO_SOUL" = TRAIT_NO_SOUL,
+ "TRAIT_NO_SPLIT_PERSONALITY" = TRAIT_NO_SPLIT_PERSONALITY,
"TRAIT_NO_STAGGER" = TRAIT_NO_STAGGER,
"TRAIT_NO_STRIP" = TRAIT_NO_STRIP,
"TRAIT_NO_THROWING" = TRAIT_NO_THROWING,
"TRAIT_NO_TRANSFORM" = TRAIT_NO_TRANSFORM,
"TRAIT_NO_TWOHANDING" = TRAIT_NO_TWOHANDING,
"TRAIT_NO_UNDERWEAR" = TRAIT_NO_UNDERWEAR,
+ "TRAIT_NO_WITHDRAWALS" = TRAIT_NO_WITHDRAWALS,
"TRAIT_NO_ZOMBIFY" = TRAIT_NO_ZOMBIFY,
"TRAIT_HIPPOCRATIC_OATH" = TRAIT_HIPPOCRATIC_OATH,
"TRAIT_NUKEIMMUNE" = TRAIT_NUKEIMMUNE,
@@ -530,6 +535,7 @@ GLOBAL_LIST_INIT(traits_by_type, list(
"TRAIT_SELF_AWARE" = TRAIT_SELF_AWARE,
"TRAIT_SELF_SURGERY" = TRAIT_SELF_SURGERY,
"TRAIT_SETTLER" = TRAIT_SETTLER,
+ "TRAIT_SHAPESHIFTED" = TRAIT_SHAPESHIFTED,
"TRAIT_SHAVED" = TRAIT_SHAVED,
"TRAIT_SHELL_RETREATED" = TRAIT_SHELL_RETREATED,
"TRAIT_SHIFTY_EYES" = TRAIT_SHIFTY_EYES,
diff --git a/code/_globalvars/traits/admin_tooling.dm b/code/_globalvars/traits/admin_tooling.dm
index 3332013b485..d44821c7857 100644
--- a/code/_globalvars/traits/admin_tooling.dm
+++ b/code/_globalvars/traits/admin_tooling.dm
@@ -49,6 +49,7 @@ GLOBAL_LIST_INIT(admin_visible_traits, list(
"TRAIT_ANTIMAGIC" = TRAIT_ANTIMAGIC,
"TRAIT_ANTIMAGIC_NO_SELFBLOCK" = TRAIT_ANTIMAGIC_NO_SELFBLOCK,
"TRAIT_ANXIOUS" = TRAIT_ANXIOUS,
+ "TRAIT_APATHETIC" = TRAIT_APATHETIC,
"TRAIT_BADDNA" = TRAIT_BADDNA,
"TRAIT_BADTOUCH" = TRAIT_BADTOUCH,
"TRAIT_BALD" = TRAIT_BALD,
@@ -204,10 +205,12 @@ GLOBAL_LIST_INIT(admin_visible_traits, list(
"TRAIT_NO_SLIP_SLIDE" = TRAIT_NO_SLIP_SLIDE,
"TRAIT_NO_SLIP_WATER" = TRAIT_NO_SLIP_WATER,
"TRAIT_NO_SOUL" = TRAIT_NO_SOUL,
+ "TRAIT_NO_SPLIT_PERSONALITY" = TRAIT_NO_SPLIT_PERSONALITY,
"TRAIT_NO_STAGGER" = TRAIT_NO_STAGGER,
"TRAIT_NO_TRANSFORM" = TRAIT_NO_TRANSFORM,
"TRAIT_NO_TWOHANDING" = TRAIT_NO_TWOHANDING,
"TRAIT_NO_UNDERWEAR" = TRAIT_NO_UNDERWEAR,
+ "TRAIT_NO_WITHDRAWALS" = TRAIT_NO_WITHDRAWALS,
"TRAIT_NO_ZOMBIFY" = TRAIT_NO_ZOMBIFY,
"TRAIT_NOBLOOD" = TRAIT_NOBLOOD,
"TRAIT_NOBREATH" = TRAIT_NOBREATH,
diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm
index d523dfc9866..51b0a579dbb 100644
--- a/code/controllers/configuration/entries/general.dm
+++ b/code/controllers/configuration/entries/general.dm
@@ -66,6 +66,10 @@
/// log login/logout
/datum/config_entry/flag/log_access
+/// log blood worm actions like entering/leaving hosts
+/datum/config_entry/flag/log_blood_worm
+ default = TRUE
+
/// Config entry which special logging of failed logins under suspicious circumstances.
/datum/config_entry/flag/log_suspicious_login
diff --git a/code/controllers/subsystem/dynamic/dynamic_ruleset_midround.dm b/code/controllers/subsystem/dynamic/dynamic_ruleset_midround.dm
index 95672517f9c..2feceaef761 100644
--- a/code/controllers/subsystem/dynamic/dynamic_ruleset_midround.dm
+++ b/code/controllers/subsystem/dynamic/dynamic_ruleset_midround.dm
@@ -435,7 +435,7 @@
max_antag_cap += prob(50) // 50% chance to get a second xeno, free!
/datum/dynamic_ruleset/midround/from_ghosts/xenomorph/can_be_selected()
- return ..() && length(find_vents()) > 0
+ return ..() && length(find_vent_spawns()) > 0
/datum/dynamic_ruleset/midround/from_ghosts/xenomorph/execute()
. = ..()
@@ -451,30 +451,53 @@
return new /mob/living/carbon/alien/larva
/datum/dynamic_ruleset/midround/from_ghosts/xenomorph/create_execute_args()
- return list(find_vents())
+ return list(find_vent_spawns())
/datum/dynamic_ruleset/midround/from_ghosts/xenomorph/assign_role(datum/mind/candidate, list/vent_list)
// xeno login gives antag datums
var/obj/vent = length(vent_list) >= 2 ? pick_n_take(vent_list) : vent_list[1]
candidate.current.move_into_vent(vent)
-/datum/dynamic_ruleset/midround/from_ghosts/xenomorph/proc/find_vents()
- var/list/vents = list()
- var/list/vent_pumps = SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/atmospherics/components/unary/vent_pump)
- for(var/obj/machinery/atmospherics/components/unary/vent_pump/temp_vent as anything in vent_pumps)
- if(QDELETED(temp_vent))
- continue
- if(!is_station_level(temp_vent.loc.z) || temp_vent.welded)
- continue
- var/datum/pipeline/temp_vent_parent = temp_vent.parents[1]
- if(!temp_vent_parent)
- continue
- // Stops Aliens getting stuck in small networks.
- // See: Security, Virology
- if(length(temp_vent_parent.other_atmos_machines) <= 20)
- continue
- vents += temp_vent
- return vents
+/datum/dynamic_ruleset/midround/from_ghosts/blood_worms
+ name = "Blood Worm Infestation"
+ config_tag = "Midround Blood Worm"
+ preview_antag_datum = /datum/antagonist/blood_worm/infestation
+ // Please set this to HEAVY_MIDROUND once dynamic has fine-grained handling for spawn times and doesn't restrict all heavy midrounds to spawning after 40 minutes.
+ // Blood worms are intended to spawn 10-30 minutes into a round. This is a band-aid fix, and the better of two evils. (wrong threat tier vs wrong round timing)
+ midround_type = LIGHT_MIDROUND
+ false_alarm_able = TRUE
+ pref_flag = ROLE_BLOOD_WORM_INFESTATION
+ candidate_role = "Blood Worm"
+ ruleset_flags = RULESET_INVADER
+ weight = 2 // For reference, Nightmare has a weight of 5.
+ min_pop = 20 // Blood worms are limited by resources, so low pop means they have a harder time getting their tail in the door.
+ min_antag_cap = 1
+ max_antag_cap = 2
+ signup_atom_appearance = /mob/living/basic/blood_worm/juvenile
+
+/datum/dynamic_ruleset/midround/from_ghosts/blood_worms/can_be_selected()
+ return ..() && length(find_vent_spawns()) > 0
+
+/datum/dynamic_ruleset/midround/from_ghosts/blood_worms/execute()
+ . = ..()
+ addtimer(CALLBACK(src, PROC_REF(announce_worms)), rand(450, 750) SECONDS)
+
+/datum/dynamic_ruleset/midround/from_ghosts/blood_worms/create_ruleset_body()
+ return new /mob/living/basic/blood_worm/hatchling
+
+/datum/dynamic_ruleset/midround/from_ghosts/blood_worms/create_execute_args()
+ return list(find_vent_spawns())
+
+/datum/dynamic_ruleset/midround/from_ghosts/blood_worms/assign_role(datum/mind/candidate, list/vent_list)
+ candidate.add_antag_datum(/datum/antagonist/blood_worm)
+ var/obj/vent = length(vent_list) >= 2 ? pick_n_take(vent_list) : vent_list[1]
+ candidate.current.move_into_vent(vent)
+
+/datum/dynamic_ruleset/midround/from_ghosts/blood_worms/proc/announce_worms()
+ priority_announce("Unidentified lifesigns detected coming aboard [station_name()]. Secure any exterior access, including ducting and ventilation.", "Lifesign Alert", ANNOUNCER_ALIENS)
+
+/datum/dynamic_ruleset/midround/from_ghosts/blood_worms/false_alarm()
+ announce_worms()
/datum/dynamic_ruleset/midround/from_ghosts/nightmare
name = "Nightmare"
diff --git a/code/controllers/subsystem/dynamic/dynamic_ruleset_roundstart.dm b/code/controllers/subsystem/dynamic/dynamic_ruleset_roundstart.dm
index 417f7164c21..8554937f752 100644
--- a/code/controllers/subsystem/dynamic/dynamic_ruleset_roundstart.dm
+++ b/code/controllers/subsystem/dynamic/dynamic_ruleset_roundstart.dm
@@ -98,6 +98,40 @@
/datum/dynamic_ruleset/roundstart/changeling/assign_role(datum/mind/candidate)
candidate.add_antag_datum(/datum/antagonist/changeling)
+// Quick thing about this event, it turns out roundstart blood worms, despite being granted a host, are worse at antaggery in practice.
+// They have their static, so unlike blood worms who spawn in the middle of the round, they just kinda tend to do nothing. So having at least 2 is ideal.
+// This way, if one ends up being a fluke, the other can hopefully pick up the slack. It's a semi-rare event, so having a massive chance to just fizzle out isn't ideal.
+/datum/dynamic_ruleset/roundstart/blood_worm
+ name = "Blood Worms"
+ config_tag = "Roundstart Blood Worm"
+ preview_antag_datum = /datum/antagonist/blood_worm
+ pref_flag = ROLE_BLOOD_WORM
+ weight = 1
+ min_pop = 10 // Resource limited, spawning on lowpop is fine.
+ max_antag_cap = list("denominator" = 29) // +1 blood worm per 30 pop.
+ repeatable = FALSE // Yeah no.
+
+/datum/dynamic_ruleset/roundstart/blood_worm/is_valid_candidate(mob/living/candidate, client/candidate_client)
+ if (!..())
+ return FALSE
+
+ var/species_type = candidate_client.prefs.read_preference(/datum/preference/choiced/species)
+ var/datum/species/species = GLOB.species_prototypes[species_type]
+
+ return !(TRAIT_NOBLOOD in species.inherent_traits)
+
+/datum/dynamic_ruleset/roundstart/blood_worm/assign_role(datum/mind/candidate)
+ if (!CAN_HAVE_BLOOD(candidate.current))
+ CRASH("A roundstart blood worm tried to spawn into a candidate mob with no blood. This shouldn't happen, because we already checked for TRAIT_NOBLOOD in species traits.")
+
+ var/mob/living/carbon/human/host = candidate.current
+ var/mob/living/basic/blood_worm/hatchling/worm = new(get_turf(host))
+
+ candidate.transfer_to(worm)
+ candidate.add_antag_datum(/datum/antagonist/blood_worm/infestation)
+
+ worm.enter_host(host, silent = TRUE, gain_progress = FALSE)
+
/datum/dynamic_ruleset/roundstart/heretic
name = "Heretics"
config_tag = "Roundstart Heretics"
diff --git a/code/datums/actions/action.dm b/code/datums/actions/action.dm
index 5b72c3c21e0..64f2f2a6c2b 100644
--- a/code/datums/actions/action.dm
+++ b/code/datums/actions/action.dm
@@ -452,3 +452,8 @@
else
source.next_click = world.time + CLICK_CD_ACTIVATE_ABILITY
INVOKE_ASYNC(src, PROC_REF(Trigger))
+
+/// Used for setting the keybind via external sources.
+/datum/action/proc/set_key(new_full_key)
+ full_key = new_full_key
+ build_all_button_icons(UPDATE_BUTTON_STATUS)
diff --git a/code/datums/brain_damage/split_personality.dm b/code/datums/brain_damage/split_personality.dm
index 0cf605afc34..57c94e60b1d 100644
--- a/code/datums/brain_damage/split_personality.dm
+++ b/code/datums/brain_damage/split_personality.dm
@@ -21,7 +21,7 @@
/datum/brain_trauma/severe/split_personality/on_gain()
var/mob/living/brain_owner = owner
- if(brain_owner.stat == DEAD || !GET_CLIENT(brain_owner) || istype(get_area(brain_owner), /area/deathmatch)) //No use assigning people to a corpse or braindead
+ if(brain_owner.stat == DEAD || !GET_CLIENT(brain_owner) || istype(get_area(brain_owner), /area/deathmatch) || HAS_TRAIT(brain_owner, TRAIT_NO_SPLIT_PERSONALITY)) //No use assigning people to a corpse or braindead
return FALSE
. = ..()
make_backseats()
diff --git a/code/datums/components/slide_under_doors.dm b/code/datums/components/slide_under_doors.dm
new file mode 100644
index 00000000000..a43e9a66de9
--- /dev/null
+++ b/code/datums/components/slide_under_doors.dm
@@ -0,0 +1,188 @@
+// A component for basic mobs that makes them able to slide under doors by right-clicking on them.
+// Sliding under doors has a configurable initial delay, and allows the mob to remain under the door indefinitely.
+// If the door is opened while the mob is under it, then the mob will be ejected from their hiding spot.
+// Instantly sliding under doors should be handled by giving the mob the PASSDOORS pass flag.
+
+/datum/component/slide_under_doors
+ can_transfer = TRUE
+
+ /// The delay for sliding under a door in deciseconds.
+ var/slide_in_delay = 0
+ /// The delay for sliding out from under a door in deciseconds.
+ var/slide_out_delay = 0
+
+ /// The user that is currently under a door, if any.
+ var/mob/living/current_user = null
+ /// The door the user is currently under, if any.
+ var/obj/machinery/door/current_door = null
+
+/datum/component/slide_under_doors/Initialize(slide_in_delay = 5 SECONDS, slide_out_delay = 1 SECONDS)
+ if (!isbasicmob(parent))
+ return COMPONENT_INCOMPATIBLE
+
+ src.slide_in_delay = slide_in_delay
+ src.slide_out_delay = slide_out_delay
+
+/datum/component/slide_under_doors/RegisterWithParent()
+ RegisterSignal(parent, COMSIG_MOB_CLIENT_LOGIN, PROC_REF(on_user_login))
+ RegisterSignal(parent, COMSIG_LIVING_UNARMED_ATTACK, PROC_REF(on_user_unarmed_attack))
+
+ notify_user()
+
+/datum/component/slide_under_doors/UnregisterFromParent()
+ UnregisterSignal(parent, list(COMSIG_MOB_CLIENT_LOGIN, COMSIG_LIVING_UNARMED_ATTACK))
+
+ if (!QDELETED(current_user) && !QDELETED(current_door))
+ slide_out_from_under_door()
+ else if (current_user || current_door)
+ unregister_user_and_door()
+
+/datum/component/slide_under_doors/proc/on_user_login(mob/living/user, client/user_client)
+ SIGNAL_HANDLER
+ notify_user()
+
+/datum/component/slide_under_doors/proc/notify_user()
+ to_chat(parent, span_notice("You can slide under doors! Right-click on a door to slide under it."))
+
+/datum/component/slide_under_doors/proc/on_user_unarmed_attack(mob/living/user, atom/target, is_adjacent, modifiers)
+ SIGNAL_HANDLER
+ if (!modifiers[RIGHT_CLICK])
+ return
+ if (!istype(target, /obj/machinery/door))
+ return
+
+ INVOKE_ASYNC(src, PROC_REF(try_slide_under_door), user, target)
+ return COMPONENT_CANCEL_ATTACK_CHAIN
+
+/datum/component/slide_under_doors/proc/try_slide_under_door(mob/living/user, obj/machinery/door/door)
+ if (!can_slide_under_door(user, door))
+ return
+
+ user.visible_message(
+ message = span_danger("\The [user] start[user.p_s()] sliding under \the [door]!"),
+ self_message = span_notice("You start sliding under \the [door]."),
+ blind_message = span_hear("You hear squeezing."),
+ )
+
+ playsound(user, 'sound/effects/footstep/gib_step.ogg', vol = 50, vary = TRUE, ignore_walls = FALSE)
+
+ if (!do_after(user, slide_in_delay, door, extra_checks = CALLBACK(src, PROC_REF(can_slide_under_door), user, door)))
+ return
+
+ slide_under_door(user, door)
+
+/datum/component/slide_under_doors/proc/can_slide_under_door(mob/living/user, obj/machinery/door/door)
+ if (!user.Adjacent(door))
+ return FALSE
+ if (user.loc == door)
+ return FALSE
+ if (!isturf(user.loc))
+ user.balloon_alert(user, "not on the ground!")
+ return FALSE
+ if (!door.IsReachableBy(user))
+ user.balloon_alert(user, "can't reach!")
+ return FALSE
+ if (HAS_TRAIT(user, TRAIT_INCAPACITATED))
+ user.balloon_alert(user, "incapacitated!")
+ return FALSE
+ if (!(door.pass_flags_self & PASSDOORS))
+ door.balloon_alert(user, "impassable!")
+ return FALSE
+ if (!door.density)
+ door.balloon_alert(user, "already open!")
+ return FALSE
+ return TRUE
+
+/datum/component/slide_under_doors/proc/slide_under_door(mob/living/user, obj/machinery/door/door)
+ user.visible_message(
+ message = span_danger("\The [user] slide[user.p_s()] under \the [door] with a pop!"),
+ self_message = span_notice("You slide under \the [door] with a pop!"),
+ blind_message = span_hear("You hear a pop."),
+ )
+
+ playsound(user, 'sound/effects/meatslap.ogg', vol = 50, vary = TRUE, ignore_walls = FALSE)
+
+ user.forceMove(door)
+
+ register_user_and_door(user, door)
+
+/datum/component/slide_under_doors/proc/try_slide_out_from_under_door(move_dir)
+ if (DOING_INTERACTION_WITH_TARGET(current_user, current_door))
+ return
+
+ var/turf/visible_turf = get_step(current_user, move_dir)
+
+ if (visible_turf)
+ visible_turf.visible_message(
+ message = span_danger("Something starts sliding out from under \the [current_door]!"),
+ blind_message = span_hear("You hear squeezing."),
+ ignored_mobs = current_user,
+ )
+
+ playsound(visible_turf, 'sound/effects/footstep/gib_step.ogg', vol = 50, vary = TRUE, ignore_walls = FALSE)
+
+ to_chat(current_user, span_notice("You start sliding out from under \the [current_door]."))
+
+ if (!do_after(current_user, slide_out_delay, current_door, timed_action_flags = IGNORE_INCAPACITATED))
+ return
+
+ slide_out_from_under_door(move_dir)
+
+/datum/component/slide_under_doors/proc/slide_out_from_under_door(move_dir)
+ // We need to store local references to the user and the door before unregistering them.
+ var/mob/living/user = current_user
+ var/obj/machinery/door/door = current_door
+
+ unregister_user_and_door()
+
+ user.forceMove(door.drop_location())
+
+ if (move_dir)
+ user.Move(get_step(user, move_dir), move_dir)
+
+ user.visible_message(
+ message = span_danger("\The [user] slide[user.p_s()] out from under \the [door] with a pop!"),
+ self_message = span_notice("You slide out from under \the [door] with a pop!"),
+ blind_message = span_hear("You hear a pop."),
+ )
+
+ playsound(user, 'sound/effects/meatslap.ogg', vol = 50, vary = TRUE, ignore_walls = FALSE)
+
+/datum/component/slide_under_doors/proc/register_user_and_door(mob/living/user, obj/machinery/door/door)
+ ADD_TRAIT(user, TRAIT_INCAPACITATED, UNDER_DOOR_TRAIT)
+ RegisterSignal(user, COMSIG_MOVABLE_MOVED, PROC_REF(on_user_moved))
+ current_user = user
+
+ RegisterSignal(door, COMSIG_QDELETING, PROC_REF(on_door_qdeleting))
+ RegisterSignal(door, COMSIG_ATOM_DENSITY_CHANGED, PROC_REF(on_door_density_changed))
+ RegisterSignal(door, COMSIG_ATOM_RELAYMOVE, PROC_REF(on_door_relaymove))
+ current_door = door
+
+/datum/component/slide_under_doors/proc/unregister_user_and_door()
+ if (current_user)
+ REMOVE_TRAITS_IN(current_user, UNDER_DOOR_TRAIT)
+ UnregisterSignal(current_user, COMSIG_MOVABLE_MOVED)
+ current_user = null
+
+ if (current_door)
+ UnregisterSignal(current_door, list(COMSIG_QDELETING, COMSIG_ATOM_DENSITY_CHANGED, COMSIG_ATOM_RELAYMOVE))
+ current_door = null
+
+/datum/component/slide_under_doors/proc/on_user_moved(mob/living/user, obj/machinery/door/door, dir, forced, list/old_locs)
+ SIGNAL_HANDLER
+ if (user.loc != door)
+ unregister_user_and_door()
+
+/datum/component/slide_under_doors/proc/on_door_qdeleting(obj/machinery/door/door, force)
+ SIGNAL_HANDLER
+ unregister_user_and_door()
+
+/datum/component/slide_under_doors/proc/on_door_density_changed(obj/machinery/door/door)
+ SIGNAL_HANDLER
+ if (!door.density)
+ slide_out_from_under_door()
+
+/datum/component/slide_under_doors/proc/on_door_relaymove(obj/machinery/door/door, mob/living/user, move_dir)
+ SIGNAL_HANDLER
+ INVOKE_ASYNC(src, PROC_REF(try_slide_out_from_under_door), move_dir)
+ return COMSIG_BLOCK_RELAYMOVE
diff --git a/code/datums/components/synth_blood.dm b/code/datums/components/synth_blood.dm
new file mode 100644
index 00000000000..7b835a752ed
--- /dev/null
+++ b/code/datums/components/synth_blood.dm
@@ -0,0 +1,39 @@
+/// Used for tracking mobs that have had synthetic blood injected into them.
+/// The higher the synth content, the more nerfed the blood is for blood worms.
+/// This decays over time while the mob is alive.
+/datum/component/synth_blood
+ // This has a performance improvement over COMPONENT_DUPE_HIGHLANDER as it skips creating a new component instance.
+ dupe_mode = COMPONENT_DUPE_UNIQUE_PASSARGS
+
+ /// 0-1 of how synthetic this mobs blood is right now.
+ var/synth_content = 0
+
+/datum/component/synth_blood/Initialize(new_synth_content = 0)
+ if (!isliving(parent))
+ return COMPONENT_INCOMPATIBLE
+ if (new_synth_content <= 0)
+ return COMPONENT_REDUNDANT
+
+ synth_content = min(new_synth_content, 1)
+
+ RegisterSignal(parent, COMSIG_LIVING_LIFE, PROC_REF(on_life))
+
+/datum/component/synth_blood/InheritComponent(datum/component/synth_blood/new_component, i_am_original, new_synth_content)
+ if (new_component)
+ new_synth_content = new_component.synth_content
+ if (new_synth_content <= 0)
+ qdel(src)
+
+ synth_content = min(new_synth_content, 1)
+
+/datum/component/synth_blood/Destroy(force)
+ UnregisterSignal(parent, COMSIG_LIVING_LIFE)
+ return ..()
+
+/datum/component/synth_blood/proc/on_life(mob/living/living_parent, seconds_per_tick, times_fired)
+ SIGNAL_HANDLER
+ if (synth_content <= 0)
+ qdel(src)
+ else if (living_parent.stat != DEAD)
+ // Ten minutes (or 600 seconds) to fully clear synth content from one to zero.
+ synth_content = max(synth_content - (1/600) * seconds_per_tick, 0)
diff --git a/code/datums/elements/anti_self_harm.dm b/code/datums/elements/anti_self_harm.dm
new file mode 100644
index 00000000000..97e32766ca9
--- /dev/null
+++ b/code/datums/elements/anti_self_harm.dm
@@ -0,0 +1,27 @@
+// Prevents a basic mob from hitting themselves by accident.
+// This is useful if you don't want self-harm as a balancing factor.
+// There are a lot of basic mobs that never benefit from hitting themselves.
+// Properly implementing this for carbons would require a more complex setup.
+
+/datum/element/anti_self_harm
+
+/datum/element/anti_self_harm/Attach(datum/target)
+ . = ..()
+ if (!isbasicmob(target))
+ return ELEMENT_INCOMPATIBLE
+ RegisterSignal(target, COMSIG_HOSTILE_PRE_ATTACKINGTARGET, PROC_REF(on_hostile_pre_attackingtarget))
+
+/datum/element/anti_self_harm/Detach(datum/target, ...)
+ UnregisterSignal(target, COMSIG_HOSTILE_PRE_ATTACKINGTARGET)
+ return ..()
+
+/datum/element/anti_self_harm/proc/on_hostile_pre_attackingtarget(mob/living/basic/parent_mob, atom/target, is_adjacent, modifiers)
+ SIGNAL_HANDLER
+ if (parent_mob != target)
+ return
+ if (modifiers[RIGHT_CLICK])
+ return
+
+ // A balloon alert would be too distracting mid-combat.
+ to_chat(parent_mob, span_warning("You decide against attacking yourself. You can still do so with right-click."))
+ return COMPONENT_HOSTILE_NO_ATTACK
diff --git a/code/datums/elements/blood_reagent.dm b/code/datums/elements/blood_reagent.dm
index 78ecae8c117..f71c1a7aca8 100644
--- a/code/datums/elements/blood_reagent.dm
+++ b/code/datums/elements/blood_reagent.dm
@@ -172,6 +172,17 @@
/datum/element/blood_reagent/proc/on_merge(datum/reagent/source, list/mix_data, amount)
SIGNAL_HANDLER
+ // Proportionally mix synth contents
+ if (source.data?[BLOOD_DATA_SYNTH_CONTENT] || mix_data?[BLOOD_DATA_SYNTH_CONTENT])
+ var/added_synth_volume = amount * mix_data?[BLOOD_DATA_SYNTH_CONTENT]
+ var/existing_synth_volume = source.volume * source.data?[BLOOD_DATA_SYNTH_CONTENT]
+
+ if (!source.data)
+ source.data = list()
+
+ // A simple weighted average that simplifies down to "total synth volume / total blood volume" i.e. "how much of the blood is synthetic"
+ source.data[BLOOD_DATA_SYNTH_CONTENT] = (added_synth_volume + existing_synth_volume) / (amount + source.volume)
+
// Presumably artificially generated blood
if (!source.data || !mix_data)
return
diff --git a/code/datums/elements/hand_organ_insertion.dm b/code/datums/elements/hand_organ_insertion.dm
new file mode 100644
index 00000000000..2320af18e4d
--- /dev/null
+++ b/code/datums/elements/hand_organ_insertion.dm
@@ -0,0 +1,83 @@
+/datum/element/hand_organ_insertion
+ element_flags = ELEMENT_BESPOKE
+ argument_hash_start_idx = 2
+
+ var/insertion_time = 0 SECONDS
+
+/datum/element/hand_organ_insertion/Attach(datum/target, insertion_time = 5 SECONDS)
+ . = ..()
+ if (!iscarbon(target))
+ return ELEMENT_INCOMPATIBLE
+
+ src.insertion_time = insertion_time
+
+ RegisterSignal(target, COMSIG_USER_ITEM_INTERACTION_SECONDARY, PROC_REF(on_item_interaction_secondary))
+
+/datum/element/hand_organ_insertion/Detach(datum/source, ...)
+ . = ..()
+
+ UnregisterSignal(source, COMSIG_USER_ITEM_INTERACTION_SECONDARY)
+
+/datum/element/hand_organ_insertion/proc/on_item_interaction_secondary(mob/living/carbon/parent, atom/target, obj/item/tool, list/modifiers)
+ SIGNAL_HANDLER
+ if (target != parent)
+ return FALSE
+ if (!isorgan(tool))
+ return FALSE
+
+ INVOKE_ASYNC(src, PROC_REF(attempt_to_insert_organ), parent, tool)
+ return TRUE
+
+/datum/element/hand_organ_insertion/proc/attempt_to_insert_organ(mob/living/carbon/user, obj/item/organ/organ)
+ if (!can_insert_organ(user, organ, feedback = TRUE))
+ return
+
+ var/zone_name = user.parse_zone_with_bodypart(organ.zone)
+
+ user.visible_message(
+ message = span_danger("\The [user] begin[user.p_s()] inserting \the [organ] into [user.p_their()] [zone_name]!"),
+ self_message = span_danger("You begin inserting \the [organ] into your [zone_name]!"),
+ blind_message = span_hear("You hear squelching!")
+ )
+
+ user.balloon_alert(user, "inserting...")
+
+ playsound(user, 'sound/items/handling/surgery/organ2.ogg', vol = 80, vary = TRUE, ignore_walls = FALSE)
+
+ if (!do_after(user, insertion_time, extra_checks = CALLBACK(src, PROC_REF(can_insert_organ), user, organ)))
+ user.balloon_alert(user, "interrupted!")
+ return
+
+ zone_name = user.parse_zone_with_bodypart(organ.zone)
+
+ user.visible_message(
+ message = span_danger("\The [user] insert[user.p_s()] \the [organ] into [user.p_their()] [zone_name]!"),
+ self_message = span_danger("You insert \the [organ] into your [zone_name]!"),
+ blind_message = span_hear("You hear a loud, final squelch!")
+ )
+
+ user.balloon_alert(user, "inserted!")
+
+ playsound(user, 'sound/items/handling/surgery/organ1.ogg', vol = 80, vary = TRUE, ignore_walls = FALSE)
+
+ user.temporarilyRemoveItemFromInventory(organ, force = TRUE)
+ organ.Insert(user)
+ organ.on_surgical_insertion(user, user, organ.zone, organ)
+
+/datum/element/hand_organ_insertion/proc/can_insert_organ(mob/living/carbon/user, obj/item/organ/organ, feedback = FALSE)
+ if (!user.get_bodypart(deprecise_zone(organ.zone)))
+ user.balloon_alert(user, "you don't have a [parse_zone(organ.zone)]!")
+ return FALSE
+
+ var/obj/item/organ/existing_organ = user.get_organ_slot(organ.slot)
+ if (existing_organ)
+ user.balloon_alert(user, "your [existing_organ] [existing_organ.p_are()] in the way!")
+ return FALSE
+
+ if (!organ.pre_surgical_insertion(user, user, organ.zone))
+ user.balloon_alert(user, "failed!")
+ return FALSE
+ if (!organ.useable)
+ user.balloon_alert(user, "unusable!")
+ return FALSE
+ return TRUE
diff --git a/code/datums/hud.dm b/code/datums/hud.dm
index 7741eeca095..4e69d701355 100644
--- a/code/datums/hud.dm
+++ b/code/datums/hud.dm
@@ -18,6 +18,7 @@ GLOBAL_ALIST_INIT(huds, alist(
DATA_HUD_AI_DETECT = new /datum/atom_hud/ai_detector(),
DATA_HUD_FAN = new /datum/atom_hud/data/human/fan_hud(),
DATA_HUD_MALF_APC = new /datum/atom_hud/data/malf_apc(),
+ DATA_HUD_BLOOD = new /datum/atom_hud/data/human/blood(),
))
/// Assoc list of traits to the huds they give.
@@ -31,6 +32,7 @@ GLOBAL_LIST_INIT(trait_to_hud, list(
TRAIT_MIME_FAN = DATA_HUD_FAN,
TRAIT_SECURITY_HUD = DATA_HUD_SECURITY_ADVANCED,
TRAIT_SECURITY_HUD_ID_ONLY = DATA_HUD_SECURITY_BASIC,
+ TRAIT_BLOOD_HUD = DATA_HUD_BLOOD,
))
/// Assoc list of traits that block other traits' huds to list of hud (traits) that they block
diff --git a/code/datums/mood.dm b/code/datums/mood.dm
index 1fc07130b13..6ee75391517 100644
--- a/code/datums/mood.dm
+++ b/code/datums/mood.dm
@@ -271,16 +271,17 @@
mood = 0
shown_mood = 0
- for(var/category in mood_events)
- var/datum/mood_event/the_event = mood_events[category]
- var/event_mood = the_event.mood_change
- event_mood *= max((event_mood > 0) ? positive_mood_modifier : negative_mood_modifier, 0)
- mood += event_mood
- if (!the_event.hidden)
- shown_mood += event_mood
+ if (!HAS_TRAIT(mob_parent, TRAIT_APATHETIC))
+ for(var/category in mood_events)
+ var/datum/mood_event/the_event = mood_events[category]
+ var/event_mood = the_event.mood_change
+ event_mood *= max((event_mood > 0) ? positive_mood_modifier : negative_mood_modifier, 0)
+ mood += event_mood
+ if (!the_event.hidden)
+ shown_mood += event_mood
- mood *= max(mood_modifier, 0)
- shown_mood *= max(mood_modifier, 0)
+ mood *= max(mood_modifier, 0)
+ shown_mood *= max(mood_modifier, 0)
switch(mood)
if (-INFINITY to MOOD_SAD4)
@@ -423,41 +424,44 @@
if(81 to INFINITY)
msg += "[span_boldwarning("I'm completely wasted.")]
"
- msg += span_notice("My current sanity: ") //Long term
- switch(sanity)
- if(SANITY_GREAT to INFINITY)
- msg += "[span_boldnicegreen("My mind feels like a temple!")]
"
- if(SANITY_NEUTRAL to SANITY_GREAT)
- msg += "[span_nicegreen("I have been feeling great lately!")]
"
- if(SANITY_DISTURBED to SANITY_NEUTRAL)
- msg += "[span_nicegreen("I have felt quite decent lately.")]
"
- if(SANITY_UNSTABLE to SANITY_DISTURBED)
- msg += "[span_warning("I'm feeling a little bit unhinged...")]
"
- if(SANITY_CRAZY to SANITY_UNSTABLE)
- msg += "[span_warning("I'm freaking out!!")]
"
- if(SANITY_INSANE to SANITY_CRAZY)
- msg += "[span_boldwarning("AHAHAHAHAHAHAHAHAHAH!!")]
"
+ if (HAS_TRAIT(mob_parent, TRAIT_APATHETIC))
+ msg += span_notice("My mood: [span_grey("I don't feel anything.")]
")
+ else
+ msg += span_notice("My current sanity: ") //Long term
+ switch(sanity)
+ if(SANITY_GREAT to INFINITY)
+ msg += "[span_boldnicegreen("My mind feels like a temple!")]
"
+ if(SANITY_NEUTRAL to SANITY_GREAT)
+ msg += "[span_nicegreen("I have been feeling great lately!")]
"
+ if(SANITY_DISTURBED to SANITY_NEUTRAL)
+ msg += "[span_nicegreen("I have felt quite decent lately.")]
"
+ if(SANITY_UNSTABLE to SANITY_DISTURBED)
+ msg += "[span_warning("I'm feeling a little bit unhinged...")]
"
+ if(SANITY_CRAZY to SANITY_UNSTABLE)
+ msg += "[span_warning("I'm freaking out!!")]
"
+ if(SANITY_INSANE to SANITY_CRAZY)
+ msg += "[span_boldwarning("AHAHAHAHAHAHAHAHAHAH!!")]
"
- msg += span_notice("My current mood: ") //Short term
- switch(mood_level)
- if(MOOD_LEVEL_SAD4)
- msg += "[span_boldwarning("I wish I was dead!")]
"
- if(MOOD_LEVEL_SAD3)
- msg += "[span_boldwarning("I feel terrible...")]
"
- if(MOOD_LEVEL_SAD2)
- msg += "[span_boldwarning("I feel very upset.")]
"
- if(MOOD_LEVEL_SAD1)
- msg += "[span_warning("I'm a bit sad.")]
"
- if(MOOD_LEVEL_NEUTRAL)
- msg += "[span_grey("I'm alright.")]
"
- if(MOOD_LEVEL_HAPPY1)
- msg += "[span_nicegreen("I feel pretty okay.")]
"
- if(MOOD_LEVEL_HAPPY2)
- msg += "[span_boldnicegreen("I feel pretty good.")]
"
- if(MOOD_LEVEL_HAPPY3)
- msg += "[span_boldnicegreen("I feel amazing!")]
"
- if(MOOD_LEVEL_HAPPY4)
- msg += "[span_boldnicegreen("I love life!")]
"
+ msg += span_notice("My current mood: ") //Short term
+ switch(mood_level)
+ if(MOOD_LEVEL_SAD4)
+ msg += "[span_boldwarning("I wish I was dead!")]
"
+ if(MOOD_LEVEL_SAD3)
+ msg += "[span_boldwarning("I feel terrible...")]
"
+ if(MOOD_LEVEL_SAD2)
+ msg += "[span_boldwarning("I feel very upset.")]
"
+ if(MOOD_LEVEL_SAD1)
+ msg += "[span_warning("I'm a bit sad.")]
"
+ if(MOOD_LEVEL_NEUTRAL)
+ msg += "[span_grey("I'm alright.")]
"
+ if(MOOD_LEVEL_HAPPY1)
+ msg += "[span_nicegreen("I feel pretty okay.")]
"
+ if(MOOD_LEVEL_HAPPY2)
+ msg += "[span_boldnicegreen("I feel pretty good.")]
"
+ if(MOOD_LEVEL_HAPPY3)
+ msg += "[span_boldnicegreen("I feel amazing!")]
"
+ if(MOOD_LEVEL_HAPPY4)
+ msg += "[span_boldnicegreen("I love life!")]
"
var/list/additional_lines = list()
SEND_SIGNAL(user, COMSIG_CARBON_MOOD_CHECK, additional_lines)
@@ -574,6 +578,9 @@
if (amount > maximum)
amount = min(amount, maximum)
+ if (HAS_TRAIT(mob_parent, TRAIT_APATHETIC))
+ amount = SANITY_NEUTRAL
+
if(amount == sanity) //Prevents stuff from flicking around.
return
diff --git a/code/datums/mood_events/generic_negative_events.dm b/code/datums/mood_events/generic_negative_events.dm
index 8bce84f73b4..fa358243f45 100644
--- a/code/datums/mood_events/generic_negative_events.dm
+++ b/code/datums/mood_events/generic_negative_events.dm
@@ -638,3 +638,8 @@
mood_change = 0
description = "I just got coated in blood. Fascinating!"
return
+
+/datum/mood_event/jabbed_with_tester
+ description = "Man, getting jabbed with that thing really sucked."
+ mood_change = -4
+ timeout = 5 MINUTES
diff --git a/code/datums/mood_events/generic_positive_events.dm b/code/datums/mood_events/generic_positive_events.dm
index cb4aa022224..b8a4e90ecc5 100644
--- a/code/datums/mood_events/generic_positive_events.dm
+++ b/code/datums/mood_events/generic_positive_events.dm
@@ -268,6 +268,11 @@
mood_change = 6
timeout = 5 MINUTES
+/datum/mood_event/blood_worm
+ description = "KILL, CONSUME, MULTIPLY, CONQUER."
+ mood_change = 999 // Makes it bold green and gives the special obj a higher priority. Blood worm hosts are apathetic, so this is otherwise meaningless.
+ hidden = TRUE
+
/datum/mood_event/family_heirloom
description = "My family heirloom is safe with me."
mood_change = 1
diff --git a/code/datums/status_effects/_status_effect.dm b/code/datums/status_effects/_status_effect.dm
index 4a569858655..8b4de47d9ea 100644
--- a/code/datums/status_effects/_status_effect.dm
+++ b/code/datums/status_effects/_status_effect.dm
@@ -100,7 +100,7 @@
/// Updates the status effect alert's maptext (if possible)
/datum/status_effect/proc/update_shown_duration()
- PRIVATE_PROC(TRUE)
+ PROTECTED_PROC(TRUE)
if(!linked_alert || !show_duration)
return
diff --git a/code/game/data_huds.dm b/code/game/data_huds.dm
index 8611d41305c..c5836e75016 100644
--- a/code/game/data_huds.dm
+++ b/code/game/data_huds.dm
@@ -68,6 +68,9 @@
/datum/atom_hud/data/malf_apc
hud_icons = list(MALF_APC_HUD)
+/datum/atom_hud/data/human/blood
+ hud_icons = list(BLOOD_HUD)
+
/* MED/SEC/DIAG HUD HOOKS */
/*
@@ -150,6 +153,21 @@ Medical HUD! Basic mode needs suit sensors on.
else
return "health-100"
+/// A helper for getting the appropriate icon state for the blood hud.
+/proc/round_blood_for_hud(mob/living/bloodbag)
+ var/blood_level = (bloodbag.get_blood_volume(apply_modifiers = TRUE) / BLOOD_VOLUME_NORMAL) * 100
+ switch(blood_level)
+ if(87.5 to INFINITY)
+ return "hudblood100"
+ if(62.5 to 87.5)
+ return "hudblood75"
+ if(37.5 to 62.5)
+ return "hudblood50"
+ if(12.5 to 37.5)
+ return "hudblood25"
+ if(-INFINITY to 12.5)
+ return "hudblood0"
+
//HOOKS
//called when a living mob changes health
@@ -496,6 +514,14 @@ Diagnostic HUDs!
SET_PLANE(holder,ABOVE_LIGHTING_PLANE,src)
set_hud_image_active(MALF_APC_HUD)
+/*~~~~~~~~~~~~
+ BLOOD FOR THE BLOOD GOD!!!
+~~~~~~~~~~~~~*/
+
+/mob/living/proc/blood_hud_set_status()
+ if (CAN_HAVE_BLOOD(src))
+ set_hud_image_state(BLOOD_HUD, round_blood_for_hud(src))
+
#define CACHED_WIDTH_INDEX "width"
#define CACHED_HEIGHT_INDEX "height"
diff --git a/code/game/objects/items/food/meatslab.dm b/code/game/objects/items/food/meatslab.dm
index 55fa78c3295..1a7d14df2af 100644
--- a/code/game/objects/items/food/meatslab.dm
+++ b/code/game/objects/items/food/meatslab.dm
@@ -506,6 +506,22 @@
) // Marble
starting_reagent_purity = 1
+/obj/item/food/meat/slab/blood_worm
+ name = "blood worm meat"
+ desc = "Who thought eating this would be a good idea? At least it's juicy..."
+ foodtypes = RAW | MEAT | BUGS | GORE
+ food_reagents = list(
+ /datum/reagent/blood = 30, // What did you expect?
+ /datum/reagent/consumable/nutriment/protein = 5, // Rich in protein.
+ /datum/reagent/consumable/nutriment/vitamin = 3, // Rich in vitamins.
+ /datum/reagent/consumable/nutriment/fat = 2, // Rather lean.
+ )
+ bite_consumption = 10
+ starting_reagent_purity = 1
+
+/obj/item/food/meat/slab/blood_worm/make_grillable()
+ AddComponent(/datum/component/grillable, /obj/item/food/meat/steak/blood_worm, rand(30 SECONDS, 90 SECONDS), TRUE, TRUE) //Add medium rare later maybe?
+
////////////////////////////////////// MEAT STEAKS ///////////////////////////////////////////////////////////
/obj/item/food/meat/steak
name = "steak"
@@ -619,6 +635,12 @@
tastes = list("pig" = 1)
foodtypes = MEAT
+/obj/item/food/meat/steak/blood_worm
+ name = "blood worm steak"
+ desc = "Now that's a bloody good steak!"
+ tastes = list("meat" = 1)
+ foodtypes = MEAT | BUGS
+
//////////////////////////////// MEAT CUTLETS ///////////////////////////////////////////////////////
//Raw cutlets
diff --git a/code/game/sound/sound_keys/sound_keys.dm b/code/game/sound/sound_keys/sound_keys.dm
index 52d97f4f8f6..d65615e707b 100644
--- a/code/game/sound/sound_keys/sound_keys.dm
+++ b/code/game/sound/sound_keys/sound_keys.dm
@@ -1136,3 +1136,10 @@
'sound/items/hard_hat/hard_hat_equip2.ogg',
'sound/items/hard_hat/hard_hat_equip3.ogg',
)
+
+/datum/sound_effect/alien_spit_acid
+ key = SFX_ALIEN_SPIT_ACID
+ file_paths = list(
+ 'sound/effects/alien/alien_spitacid.ogg',
+ 'sound/effects/alien/alien_spitacid2.ogg'
+ )
diff --git a/code/modules/antagonists/_common/antag_datum.dm b/code/modules/antagonists/_common/antag_datum.dm
index 1ba92e66292..4e6ade226cd 100644
--- a/code/modules/antagonists/_common/antag_datum.dm
+++ b/code/modules/antagonists/_common/antag_datum.dm
@@ -194,6 +194,10 @@ GLOBAL_LIST_EMPTY(antagonists)
if(old_body)
info_button.Remove(old_body)
info_button.Grant(new_body)
+ if (antag_moodlet)
+ if (old_body)
+ clear_antag_moodies(old_body)
+ give_antag_moodies(new_body)
apply_innate_effects(new_body)
if(new_body.stat != DEAD)
new_body.add_to_current_living_antags()
@@ -364,18 +368,20 @@ GLOBAL_LIST_EMPTY(antagonists)
/**
* Proc that assigns this antagonist's ascribed moodlet to the player.
*/
-/datum/antagonist/proc/give_antag_moodies()
+/datum/antagonist/proc/give_antag_moodies(mob/living/mob_override)
if(!antag_moodlet)
return
- owner.current.add_mood_event("antag_moodlet_[type]", antag_moodlet)
+ var/mob/living/target = mob_override || owner.current
+ target.add_mood_event("antag_moodlet_[type]", antag_moodlet)
/**
* Proc that removes this antagonist's ascribed moodlet from the player.
*/
-/datum/antagonist/proc/clear_antag_moodies()
+/datum/antagonist/proc/clear_antag_moodies(mob/living/mob_override)
if(!antag_moodlet)
return
- owner.current.clear_mood_event("antag_moodlet_[type]")
+ var/mob/living/target = mob_override || owner.current
+ target.clear_mood_event("antag_moodlet_[type]")
/**
* Proc that will return the team this antagonist belongs to, when called. Helpful with antagonists that may belong to multiple potential teams in a single round.
diff --git a/code/modules/antagonists/blood_worm/actions/_base_action.dm b/code/modules/antagonists/blood_worm/actions/_base_action.dm
new file mode 100644
index 00000000000..0dd381b6d79
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/actions/_base_action.dm
@@ -0,0 +1,21 @@
+/datum/action/cooldown/mob_cooldown/blood_worm
+ abstract_type = /datum/action/cooldown/mob_cooldown/blood_worm
+
+ background_icon = 'icons/mob/actions/backgrounds.dmi'
+ background_icon_state = "bg_demon"
+
+ button_icon = 'icons/mob/actions/actions_blood_worm.dmi'
+
+ overlay_icon = 'icons/mob/actions/backgrounds.dmi'
+ overlay_icon_state = "bg_demon_border"
+ active_overlay_icon_state = "bg_spell_border_active_red"
+
+ ranged_mousepointer = 'icons/effects/mouse_pointers/weapon_pointer.dmi'
+
+/datum/action/cooldown/mob_cooldown/blood_worm/set_click_ability(mob/on_who)
+ . = ..()
+ build_all_button_icons(UPDATE_BUTTON_OVERLAY)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/unset_click_ability(mob/on_who, refund_cooldown)
+ . = ..()
+ build_all_button_icons(UPDATE_BUTTON_OVERLAY)
diff --git a/code/modules/antagonists/blood_worm/actions/cocoon.dm b/code/modules/antagonists/blood_worm/actions/cocoon.dm
new file mode 100644
index 00000000000..2fa5facf264
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/actions/cocoon.dm
@@ -0,0 +1,405 @@
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon
+ cooldown_time = 30 SECONDS
+ shared_cooldown = NONE
+
+ click_to_activate = FALSE
+
+ var/cocoon_type = null
+ var/obj/structure/blood_worm_cocoon/cocoon = null
+
+ var/new_worm_type = null
+
+ var/total_blood_required = 0
+
+ var/timer_id = null
+
+ var/cocoon_time = 30 SECONDS
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/Grant(mob/granted_to)
+ . = ..()
+ if (!owner)
+ return
+
+ RegisterSignal(owner, COMSIG_MOB_STATCHANGE, PROC_REF(on_worm_stat_changed), override = TRUE)
+ RegisterSignal(owner, COMSIG_BLOOD_WORM_CONSUMED_BLOOD, PROC_REF(update_status_on_signal))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/Remove(mob/removed_from)
+ if (!QDELETED(cocoon))
+ cancel()
+ UnregisterSignal(owner, COMSIG_BLOOD_WORM_CONSUMED_BLOOD)
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/IsAvailable(feedback)
+ if (!istype(owner, /mob/living/basic/blood_worm))
+ return FALSE
+ if (!ispath(cocoon_type, /obj/structure/blood_worm_cocoon))
+ return FALSE
+ if (!ispath(new_worm_type, /mob/living/basic/blood_worm))
+ return FALSE
+ if (HAS_TRAIT(owner, TRAIT_SHAPESHIFTED))
+ if (feedback)
+ owner.balloon_alert(owner, "not while shapeshifted!")
+ return FALSE
+ if (!isturf(owner.loc))
+ if (feedback)
+ owner.balloon_alert(owner, "get on the ground!")
+ return FALSE
+ if (!check_consumed_blood(feedback))
+ return FALSE
+ if (!QDELETED(cocoon))
+ return FALSE
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/Activate(atom/target)
+ owner.visible_message(
+ message = span_danger("\The [owner] start[owner.p_s()] growing a cocoon!"),
+ self_message = span_notice("You start growing a cocoon."),
+ blind_message = span_hear("You start hearing fleshy knitting!")
+ )
+
+ if (!do_after(owner, 5 SECONDS, extra_checks = CALLBACK(src, PROC_REF(check_consumed_blood))))
+ return FALSE
+
+ owner.visible_message(
+ message = span_danger("\The [owner] enter[owner.p_s()] a cocoon!"),
+ self_message = span_green("You enter your freshly grown cocoon!"),
+ blind_message = span_hear("You stop hearing fleshy knitting!")
+ )
+
+ cocoon = new cocoon_type(get_turf(owner))
+
+ playsound(cocoon, 'sound/effects/blob/blobattack.ogg', vol = 60, vary = TRUE, ignore_walls = FALSE)
+
+ owner.forceMove(cocoon)
+
+ owner.add_traits(list(TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED, TRAIT_MUTE, TRAIT_EMOTEMUTE), REF(src))
+
+ RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_worm_moved))
+ RegisterSignal(cocoon, COMSIG_QDELETING, PROC_REF(on_cocoon_qdel))
+
+ INVOKE_ASYNC(src, PROC_REF(handle_timer))
+
+ return TRUE
+
+/// Override this if you want special timer behaviors like polling ghosts for hatchling candidates.
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/handle_timer()
+ timer_id = addtimer(CALLBACK(src, PROC_REF(finalize)), cocoon_time, TIMER_UNIQUE | TIMER_STOPPABLE | TIMER_DELETE_ME)
+
+/// Called upon successfully finishing the incubation process.
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/finalize()
+ var/mob/living/basic/blood_worm/new_worm = new new_worm_type(get_turf(cocoon))
+
+ transfer(owner, new_worm)
+
+ for (var/mob/living/unfortunate_observer in view(3, cocoon))
+ if (new_worm.faction_check_atom(unfortunate_observer))
+ continue // Don't harm our siblings.
+ if (unfortunate_observer.stat == DEAD)
+ continue // Harms potential hosts.
+
+ unfortunate_observer.visible_message(
+ message = span_danger("\The [unfortunate_observer] is splashed with a wave of corrosive blood!"),
+ self_message = span_userdanger("You're splashed with a wave of corrosive blood! YEOWCH!"),
+ blind_message = span_hear("You hear sizzling!")
+ )
+
+ unfortunate_observer.Knockdown(3 SECONDS)
+ unfortunate_observer.adjust_fire_loss(rand(30, 50))
+
+ var/range = 4 - get_dist(cocoon, unfortunate_observer)
+ unfortunate_observer.throw_at(get_ranged_target_turf_direct(cocoon, unfortunate_observer, range), range = range, speed = 2)
+
+ for (var/turf/turf in view(3, cocoon))
+ if (prob(100 - get_dist(cocoon, turf) * 20))
+ new /obj/effect/decal/cleanable/blood(turf)
+
+ playsound(cocoon, 'sound/effects/splat.ogg', vol = 100, vary = TRUE, ignore_walls = FALSE)
+
+ shared_unregister_cocoon()
+
+ qdel(owner)
+
+/// Transfers the owning blood worm from one worm mob to another.
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/transfer(mob/living/basic/blood_worm/old_worm, mob/living/basic/blood_worm/new_worm)
+ old_worm.mind?.transfer_to(new_worm)
+
+ new_worm.id_number = old_worm.id_number
+ new_worm.update_name()
+
+ // Safety check: If someone gets turned into a blood worm, they need to keep their old mind name.
+ if (new_worm.mind.name == old_worm.real_name)
+ new_worm.mind.name = new_worm.real_name
+
+ new_worm.consumed_normal_blood = old_worm.consumed_normal_blood
+ new_worm.consumed_synth_blood = old_worm.consumed_synth_blood
+
+ new_worm.spit_action?.set_key(old_worm.spit_action?.full_key)
+ new_worm.leech_action?.set_key(old_worm.leech_action?.full_key)
+ new_worm.invade_action?.set_key(old_worm.invade_action?.full_key)
+ new_worm.cocoon_action?.set_key(old_worm.cocoon_action?.full_key)
+
+ new_worm.transfuse_action?.set_key(old_worm.transfuse_action?.full_key)
+ new_worm.eject_action?.set_key(old_worm.eject_action?.full_key)
+ new_worm.revive_action?.set_key(old_worm.revive_action?.full_key)
+
+ new_worm.cocoon_action?.StartCooldown()
+
+/// Cancels the incubation process, destroying the cocoon early.
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/cancel()
+ cocoon.visible_message(
+ message = span_danger("\The [cocoon] fall[cocoon.p_s()] apart, expelling \the [owner] within."),
+ blind_message = span_danger("You hear a splat!"),
+ ignored_mobs = owner
+ )
+
+ if (!QDELETED(owner) && owner.stat != DEAD)
+ to_chat(owner, span_userdanger("Your cocoon falls apart!"))
+
+ playsound(cocoon, 'sound/effects/splat.ogg', vol = 60, vary = TRUE, ignore_walls = FALSE)
+
+ // A little less punishing since you need a do_after to set it up again after anyway, and because this can occur due to adults canceling Reproduce for meta reasons outside of their control.
+ StartCooldown(10 SECONDS)
+
+ shared_unregister_cocoon()
+
+/// Unregisters the cocoon. Used by both [proc/cancel] and [proc/finalize].
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/shared_unregister_cocoon()
+ UnregisterSignal(owner, COMSIG_MOVABLE_MOVED)
+ UnregisterSignal(cocoon, COMSIG_QDELETING)
+
+ owner.remove_traits(list(TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED, TRAIT_MUTE, TRAIT_EMOTEMUTE), REF(src))
+
+ if (!QDELETED(owner))
+ owner.forceMove(cocoon.drop_location())
+
+ if (!QDELETED(cocoon))
+ qdel(cocoon)
+
+ cocoon = null
+
+ if (timer_id)
+ deltimer(timer_id)
+ timer_id = null
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/on_worm_stat_changed(datum/source, new_stat, old_stat)
+ SIGNAL_HANDLER
+ if (cocoon && old_stat != DEAD && new_stat == DEAD) // Alive -> Dead
+ cancel()
+ update_status_on_signal(source, new_stat, old_stat)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/on_worm_moved(datum/source, atom/old_loc, dir, forced, list/old_locs)
+ SIGNAL_HANDLER
+ cancel()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/on_cocoon_qdel(datum/source)
+ SIGNAL_HANDLER
+ cancel()
+
+/// Checks if the blood worm has consumed enough blood to use this action.
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/proc/check_consumed_blood(feedback = FALSE)
+ var/mob/living/basic/blood_worm/worm = owner
+ var/total_consumed_blood = worm.get_consumed_blood()
+
+ if (total_consumed_blood < total_blood_required)
+ if (feedback)
+ worm.balloon_alert(worm, "only at [FLOOR(total_consumed_blood / total_blood_required * 100, 1)]% of required growth!")
+ return FALSE
+ return TRUE
+
+/obj/structure/blood_worm_cocoon
+ icon = 'icons/mob/nonhuman-player/blood_worm_32x32.dmi'
+
+ faction = list(FACTION_BLOOD_WORM)
+
+ density = TRUE
+ anchored = TRUE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/hatchling
+ name = "Mature"
+ desc = "Enter incubation in a cocoon, emerging as a juvenile blood worm."
+
+ button_icon_state = "mature_hatchling"
+
+ cocoon_type = /obj/structure/blood_worm_cocoon/hatchling
+ new_worm_type = /mob/living/basic/blood_worm/juvenile
+
+ total_blood_required = 500
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/hatchling/Activate(atom/target)
+ if (tgui_alert(owner, "Are you sure? After [cocoon_time / 10] seconds, you will become a juvenile, gaining stat increases and the ability to spit corrosive blood, but losing the ability to ventcrawl.", "Mature", list("Yes", "No"), 30 SECONDS) != "Yes")
+ return
+ if (!IsAvailable(feedback = TRUE))
+ return
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/hatchling/transfer(mob/living/basic/blood_worm/old_worm, mob/living/basic/blood_worm/new_worm)
+ . = ..()
+
+ log_blood_worm("[key_name(new_worm)] finished maturing into a juvenile blood worm")
+
+/obj/structure/blood_worm_cocoon/hatchling
+ name = "small blood cocoon"
+ desc = "The incubation cocoon of a hatchling blood worm. Its surface is slowly shifting."
+
+ icon_state = "cocoon-small"
+
+ max_integrity = 100
+ damage_deflection = 10
+
+/obj/structure/blood_worm_cocoon/hatchling/examine(mob/user)
+ return ..() + span_warning("It can be broken to prevent the blood worm from maturing.")
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/juvenile
+ name = "Mature"
+ desc = "Enter incubation in a cocoon, emerging as an adult blood worm."
+
+ button_icon_state = "mature_juvenile"
+
+ cocoon_type = /obj/structure/blood_worm_cocoon/juvenile
+ new_worm_type = /mob/living/basic/blood_worm/adult
+
+ total_blood_required = 1500
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/juvenile/Activate(atom/target)
+ if (tgui_alert(owner, "Are you sure? After [cocoon_time / 10] seconds, you will become an adult, gaining stat increases and the ability to spit bursts of corrosive blood by right-clicking with Spit Blood while outside of a host.", "Mature", list("Yes", "No"), 30 SECONDS) != "Yes")
+ return
+ if (!IsAvailable(feedback = TRUE))
+ return
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/juvenile/transfer(mob/living/basic/blood_worm/old_worm, mob/living/basic/blood_worm/new_worm)
+ . = ..()
+
+ log_blood_worm("[key_name(new_worm)] finished maturing into an adult blood worm")
+
+/obj/structure/blood_worm_cocoon/juvenile
+ name = "medium blood cocoon"
+ desc = "The incubation cocoon of a juvenile blood worm. Its surface is slowly shifting."
+
+ icon_state = "cocoon-medium"
+
+ max_integrity = 150
+ damage_deflection = 15
+
+/obj/structure/blood_worm_cocoon/juvenile/examine(mob/user)
+ return ..() + span_warning("It can be broken to prevent the blood worm from maturing, but it looks rather tough.")
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult
+ name = "Reproduce"
+ desc = "Enter incubation in a cocoon, creating 4 new hatchlings including yourself."
+
+ button_icon_state = "reproduce"
+
+ cocoon_type = /obj/structure/blood_worm_cocoon/adult
+ new_worm_type = /mob/living/basic/blood_worm/hatchling
+
+ total_blood_required = 0
+
+ var/num_hatchlings = 3 // in addition to the original
+
+ var/list/candidates = null
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/Grant(mob/granted_to)
+ . = ..()
+ if (!owner)
+ return
+
+ RegisterSignal(SSdcs, COMSIG_GLOB_MOB_LOGGED_IN, PROC_REF(update_status_on_signal))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/Remove(mob/removed_from)
+ UnregisterSignal(SSdcs, COMSIG_GLOB_MOB_LOGGED_IN, PROC_REF(update_status_on_signal))
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/Activate(atom/target)
+ if (tgui_alert(owner, "Are you sure? After [cocoon_time / 10] seconds, you will create [num_hatchlings + 1] new hatchlings, including yourself.", "Reproduce", list("Yes", "No"), 30 SECONDS) != "Yes")
+ return
+ if (!IsAvailable(feedback = TRUE))
+ return
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/handle_timer()
+ cocoon.balloon_alert(owner, "polling ghosts")
+
+ candidates = SSpolling.poll_ghost_candidates(
+ question = "Would you like to become a newly hatched blood worm? (x[num_hatchlings])",
+ role = ROLE_BLOOD_WORM_INFESTATION,
+ check_jobban = ROLE_BLOOD_WORM_INFESTATION,
+ poll_time = cocoon_time,
+ ignore_category = POLL_IGNORE_BLOOD_WORM,
+ alert_pic = cocoon_type, // The hatchling icon is too small, and a well-cropped juvenile icon is already used for the main spawn event.
+ jump_target = cocoon,
+ role_name_text = "blood worm",
+ amount_to_pick = num_hatchlings
+ )
+
+ var/num_candidates = length(candidates)
+
+ if (QDELETED(cocoon))
+ send_apology_to_candidates() // If this is reached, then [proc/cancel], which normally handles apologizing to the candidates, has already been called before the poll was finished.
+ return
+ if (num_candidates <= 0)
+ cancel()
+ owner.balloon_alert(owner, "no candidates!") // We can't host this balloon alert on a deleted cocoon.
+ return
+ if (num_candidates < num_hatchlings && tgui_alert(owner, "There are only [num_candidates]/[num_hatchlings] candidates for hatchlings, want to proceed anyway?", "Ghost Shortage", list("Yes", "No"), 10 SECONDS) != "Yes")
+ cancel()
+ return
+
+ finalize() // The poll is the timer.
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/finalize()
+ for (var/mob/candidate as anything in candidates)
+ if (isnull(candidate) || isnull(candidate.key) || isnull(candidate.client))
+ continue
+
+ // The crew now has 3 new problems to deal with.
+ var/mob/living/basic/blood_worm/hatchling/new_hatchling = new(cocoon.drop_location())
+ var/datum/mind/fresh_mind = new(candidate.key)
+
+ fresh_mind.transfer_to(new_hatchling, force_key_move = TRUE)
+ fresh_mind.add_antag_datum(/datum/antagonist/blood_worm/infestation)
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/transfer(mob/living/basic/blood_worm/old_worm, mob/living/basic/blood_worm/new_worm)
+ . = ..()
+
+ new_worm.reset_consumed_blood()
+
+ SEND_SIGNAL(new_worm, COMSIG_BLOOD_WORM_REPRODUCED)
+
+ log_blood_worm("[key_name(new_worm)] finished reproducing, resetting their growth back into a hatchling blood worm")
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/cancel()
+ send_apology_to_candidates()
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/shared_unregister_cocoon()
+ candidates = null
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult/proc/send_apology_to_candidates()
+ for (var/mob/candidate as anything in candidates)
+ if (isnull(candidate) || isnull(candidate.key) || isnull(candidate.client))
+ continue
+
+ // Sucks, but that's just how it is sometimes.
+ to_chat(candidate, span_warning("The blood worm cocoon you rolled a hatchling spot for was canceled. Sorry."))
+
+/obj/structure/blood_worm_cocoon/adult
+ name = "large blood cocoon"
+ desc = "The incubation cocoon of an adult blood worm. You can see many faint shadows within."
+
+ icon_state = "cocoon-large"
+
+ max_integrity = 200
+ damage_deflection = 20
+
+/obj/structure/blood_worm_cocoon/adult/examine(mob/user)
+ return ..() + span_warning("It can be broken to prevent the blood worm from reproducing, but it looks extremely tough.")
diff --git a/code/modules/antagonists/blood_worm/actions/inject_blood.dm b/code/modules/antagonists/blood_worm/actions/inject_blood.dm
new file mode 100644
index 00000000000..57f12a05ace
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/actions/inject_blood.dm
@@ -0,0 +1,243 @@
+#define REQUIRED_ACCUMULATION(wound) (1 + (wound.severity - 1) * 0.3)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/inject
+ name = "Inject Blood"
+ desc = "Inject your blood into the damaged tissues of your host, healing them in exchange for your own health."
+
+ button_icon_state = "inject_blood"
+
+ cooldown_time = 30 SECONDS
+ shared_cooldown = NONE
+
+ click_to_activate = FALSE
+
+ check_flags = NONE
+
+ var/health_cost = 0
+ var/minimum_health = 10
+
+ var/status_effect_type = null
+
+/datum/action/cooldown/mob_cooldown/blood_worm/inject/New(Target, original)
+ . = ..()
+ RegisterSignal(target, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(update_status_on_signal))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/inject/Destroy()
+ UnregisterSignal(target, COMSIG_LIVING_HEALTH_UPDATE)
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/inject/IsAvailable(feedback)
+ if (!ishuman(owner) && !istype(owner, /mob/living/basic/blood_worm))
+ return FALSE
+
+ var/mob/living/basic/blood_worm/worm = target
+
+ if (worm.get_worm_health() - health_cost < minimum_health)
+ if (feedback)
+ owner.balloon_alert(owner, "out of blood!")
+ return FALSE
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/inject/Activate(atom/target)
+ var/mob/living/basic/blood_worm/worm = src.target
+ var/mob/living/carbon/human/host = worm.host
+
+ host.apply_status_effect(status_effect_type, worm)
+
+ host.visible_message(
+ message = span_danger("[host]'s wounds start healing unnaturally quickly!"),
+ ignored_mobs = owner
+ )
+
+ to_chat(owner, span_notice("You inject blood into the damaged tissues of your host."))
+
+ worm.adjust_worm_health(-health_cost)
+
+ return ..()
+
+/datum/status_effect/blood_worm_transfuse
+ id = "blood_worm_transfuse"
+ duration = 20 SECONDS
+ show_duration = TRUE
+ alert_type = /atom/movable/screen/alert/status_effect/blood_worm_transfuse
+ status_type = STATUS_EFFECT_REPLACE
+ processing_speed = STATUS_EFFECT_PRIORITY
+
+ var/damage_regen_rate = 0
+
+ var/wound_regen_rate = 0
+ var/wound_regen_accumulation = 0
+
+ var/organ_regen_rate = 0
+
+ var/trauma_regen_rate = 0
+ var/trauma_regen_accumulation = 0
+
+ var/atom/movable/screen/alert/status_effect/worm_alert = null
+ var/mob/living/basic/blood_worm/worm = null
+
+ var/last_tick_time = 0
+
+/datum/status_effect/blood_worm_transfuse/on_creation(mob/living/new_owner, mob/living/basic/blood_worm/new_worm)
+ . = ..()
+ if (!.)
+ return
+
+ attach_to_worm(new_worm)
+ last_tick_time = world.time
+
+/datum/status_effect/blood_worm_transfuse/Destroy()
+ detach_from_worm()
+ return ..()
+
+/datum/status_effect/blood_worm_transfuse/tick(seconds_between_ticks)
+ var/need_mob_update = FALSE
+
+ need_mob_update |= heal_damage(seconds_between_ticks)
+ need_mob_update |= heal_wounds(seconds_between_ticks)
+ need_mob_update |= heal_organs(seconds_between_ticks)
+ heal_traumas(seconds_between_ticks)
+
+ if (need_mob_update)
+ owner.updatehealth()
+
+ last_tick_time = world.time
+
+/datum/status_effect/blood_worm_transfuse/update_shown_duration()
+ . = ..()
+
+ if (worm_alert && linked_alert)
+ worm_alert.maptext = linked_alert.maptext
+
+/datum/status_effect/blood_worm_transfuse/proc/heal_damage(seconds_between_ticks)
+ var/healing_left = damage_regen_rate * seconds_between_ticks
+
+ if (owner.get_brute_loss() > 0 && healing_left > 0)
+ var/amount_healed = max(0, owner.adjust_brute_loss(-healing_left, forced = TRUE, updating_health = FALSE))
+ healing_left -= amount_healed
+ . |= amount_healed
+
+ if (owner.get_fire_loss() > 0 && healing_left > 0)
+ var/amount_healed = max(0, owner.adjust_fire_loss(-healing_left, forced = TRUE, updating_health = FALSE))
+ healing_left -= amount_healed
+ . |= amount_healed
+
+ if (owner.get_tox_loss() > 0 && healing_left > 0)
+ var/amount_healed = max(0, owner.adjust_tox_loss(-healing_left, forced = TRUE, updating_health = FALSE))
+ healing_left -= amount_healed
+ . |= amount_healed
+
+// I tried to set this up reasonably with SPT_PROB(), but it was too inconsistent, especially for wounds with high severity.
+// So I switched to an accumulation system instead. This way, the blood worm gets a consistent return on their health investment.
+/datum/status_effect/blood_worm_transfuse/proc/heal_wounds(seconds_between_ticks)
+ var/mob/living/carbon/human/host = owner
+
+ if (length(host.all_wounds))
+ wound_regen_accumulation += wound_regen_rate * seconds_between_ticks
+ else
+ wound_regen_accumulation = 0
+ return
+
+ while (wound_regen_accumulation >= 1 && length(host.all_wounds))
+ var/list/candidates = list()
+
+ for (var/datum/wound/wound in host.all_wounds)
+ if (wound_regen_accumulation >= REQUIRED_ACCUMULATION(wound))
+ candidates += wound
+
+ if (!length(candidates))
+ return
+
+ var/datum/wound/wound_to_heal = pick(candidates)
+ wound_regen_accumulation = max(0, wound_regen_accumulation - REQUIRED_ACCUMULATION(wound_to_heal))
+ wound_to_heal.remove_wound()
+ . = TRUE
+
+/datum/status_effect/blood_worm_transfuse/proc/heal_organs(seconds_between_ticks)
+ var/mob/living/carbon/human/host = owner
+
+ for (var/obj/item/organ/organ in host.organs)
+ // I thought about making this require ORGAN_ORGANIC, but changelings can heal robotic organs, so why not blood worms?
+ // In addition, none of the other blood worm code gives a shit about targets being organic, so this is more consistent.
+ . |= organ.apply_organ_damage(-organ_regen_rate * seconds_between_ticks)
+
+/datum/status_effect/blood_worm_transfuse/proc/heal_traumas(seconds_between_ticks)
+ var/mob/living/carbon/human/host = owner
+
+ if (host.has_trauma_type(resilience = TRAUMA_RESILIENCE_SURGERY))
+ trauma_regen_accumulation += trauma_regen_rate * seconds_between_ticks
+ else
+ trauma_regen_accumulation = 0
+ return
+
+ while (trauma_regen_accumulation >= 1 && host.has_trauma_type(resilience = TRAUMA_RESILIENCE_SURGERY))
+ host.cure_trauma_type(resilience = TRAUMA_RESILIENCE_SURGERY)
+ trauma_regen_accumulation = max(0, trauma_regen_accumulation - 1)
+
+/datum/status_effect/blood_worm_transfuse/proc/attach_to_worm(mob/living/basic/blood_worm/new_worm)
+ worm = new_worm
+
+ if (!worm || !alert_type)
+ return
+
+ RegisterSignal(worm, COMSIG_QDELETING, PROC_REF(detach_from_worm))
+
+ worm_alert = worm.throw_alert(id, alert_type)
+ worm_alert.attached_effect = src
+ update_shown_duration()
+
+/datum/status_effect/blood_worm_transfuse/proc/detach_from_worm(datum/source, force)
+ SIGNAL_HANDLER
+
+ UnregisterSignal(worm, COMSIG_QDELETING)
+
+ worm_alert = null
+ worm.clear_alert(id)
+
+ worm = null
+
+/atom/movable/screen/alert/status_effect/blood_worm_transfuse
+ name = "Blood Injection"
+ desc = "The injected blood is rapidly healing your host."
+ icon = 'icons/mob/actions/actions_blood_worm.dmi'
+ icon_state = "inject_blood"
+
+/atom/movable/screen/alert/status_effect/blood_worm_transfuse/Initialize(mapload, datum/hud/hud_owner)
+ . = ..()
+ underlays += mutable_appearance('icons/mob/actions/backgrounds.dmi', "bg_demon", layer = layer, offset_spokesman = src, plane = plane)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/inject/hatchling
+ health_cost = 20
+ cooldown_time = 30 SECONDS // Effective host healing rate is 4 hp/s, Effective worm consumption rate is 0.667 hp/s, Worm max health is 2.5x less than host max health, Worm heals at 0.3 hp/s
+ status_effect_type = /datum/status_effect/blood_worm_transfuse/hatchling
+
+/datum/status_effect/blood_worm_transfuse/hatchling
+ damage_regen_rate = 6 // 20 s * 6 hp/s = 120 hp, note that major host healing is expected as the worm itself is very vulnerable to bleeding.
+ wound_regen_rate = 1 / 6 // One wound every 6 seconds, +30% per wound severity level.
+ organ_regen_rate = 2.5 // 20 s * 2.5 hp/s = 50 hp
+ trauma_regen_rate = 1 / 6 // One trauma every 6 seconds.
+
+/datum/action/cooldown/mob_cooldown/blood_worm/inject/juvenile
+ health_cost = 35
+ cooldown_time = 40 SECONDS // Effective host healing rate is 4 hp/s, Effective worm consumption rate is 0.875 hp/s, Worm max health is 1.66x less than host max health, Worm heals at 0.4 hp/s
+ status_effect_type = /datum/status_effect/blood_worm_transfuse/juvenile
+
+/datum/status_effect/blood_worm_transfuse/juvenile
+ damage_regen_rate = 8 // 20 s * 8 hp/s = 160 hp, note that major host healing is expected as the worm itself is very vulnerable to bleeding.
+ wound_regen_rate = 1 / 5 // One wound every 5 seconds, +30% per wound severity level.
+ organ_regen_rate = 4 // 20 s * 4 hp/s = 80 hp
+ trauma_regen_rate = 1 / 5 // One trauma every 5 seconds.
+
+/datum/action/cooldown/mob_cooldown/blood_worm/inject/adult
+ health_cost = 50
+ cooldown_time = 50 SECONDS // Effective host healing rate is 4 hp/s, Effective worm consumption rate is 1 hp/s, Worm max health is 1.11x less than host max health, Worm heals at 0.5 hp/s
+ status_effect_type = /datum/status_effect/blood_worm_transfuse/adult
+
+/datum/status_effect/blood_worm_transfuse/adult
+ damage_regen_rate = 10 // 20 s * 10 hp/s = 200 hp, note that major host healing is expected as the worm itself is very vulnerable to bleeding.
+ wound_regen_rate = 1 / 4 // One wound every 4 seconds, +30% per wound severity level.
+ organ_regen_rate = 5 // 20 s * 5 hp/s = 100 hp, which is also the standard organ health threshold.
+ trauma_regen_rate = 1 / 4 // One trauma every 4 seconds.
+
+#undef REQUIRED_ACCUMULATION
diff --git a/code/modules/antagonists/blood_worm/actions/invade_corpse.dm b/code/modules/antagonists/blood_worm/actions/invade_corpse.dm
new file mode 100644
index 00000000000..26ad54e931e
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/actions/invade_corpse.dm
@@ -0,0 +1,66 @@
+/datum/action/cooldown/mob_cooldown/blood_worm/invade
+ name = "Invade Corpse"
+ desc = "Invade a humanoid corpse, taking it as your host."
+
+ button_icon_state = "invade_corpse"
+
+ cooldown_time = 0 SECONDS
+ shared_cooldown = NONE
+
+ unset_after_click = FALSE // Unsetting is handled explicitly.
+
+/datum/action/cooldown/mob_cooldown/blood_worm/invade/IsAvailable(feedback)
+ if (!istype(owner, /mob/living/basic/blood_worm))
+ return FALSE
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/invade/Activate(atom/target)
+ if (!ishuman(target))
+ return FALSE
+
+ var/mob/living/basic/blood_worm/worm = owner
+ var/mob/living/carbon/human/victim = target
+
+ if (!worm.Adjacent(victim))
+ victim.balloon_alert(worm, "too far away!")
+ return FALSE
+ if (!victim.IsReachableBy(worm))
+ victim.balloon_alert(worm, "can't reach!")
+ return FALSE
+
+ unset_click_ability(worm, refund_cooldown = FALSE) // If you fail after this point, it's because your attempt got interrupted or because the victim is invalid.
+
+ if (!invade_check(worm, victim, feedback = TRUE))
+ return TRUE // Don't bite the victim.
+
+ worm.visible_message(
+ message = span_danger("\The [worm] starts entering \the [victim]!"),
+ self_message = span_notice("You start entering \the [victim]."),
+ blind_message = span_hear("You hear squeezing.")
+ )
+
+ if (!do_after(worm, 5 SECONDS, victim, extra_checks = CALLBACK(src, PROC_REF(invade_check), worm, victim)))
+ return TRUE // Don't bite the victim.
+
+ worm.enter_host(victim)
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/invade/proc/invade_check(mob/living/basic/blood_worm/worm, mob/living/carbon/human/victim, feedback = FALSE)
+ if (HAS_TRAIT(victim, TRAIT_BLOOD_WORM_HOST))
+ if (feedback)
+ victim.balloon_alert(worm, "already a host!")
+ return FALSE
+ if (victim.stat != DEAD)
+ if (feedback)
+ victim.balloon_alert(worm, "still alive!")
+ return FALSE
+ if (!CAN_HAVE_BLOOD(victim))
+ if (feedback)
+ victim.balloon_alert(worm, "no blood!")
+ return FALSE
+ if (victim.get_blood_volume() + worm.health * BLOOD_WORM_HEALTH_TO_BLOOD <= worm.get_eject_volume_threshold())
+ if (feedback)
+ victim.balloon_alert(worm, "not enough blood for control!")
+ return FALSE
+ return TRUE
diff --git a/code/modules/antagonists/blood_worm/actions/leave_host.dm b/code/modules/antagonists/blood_worm/actions/leave_host.dm
new file mode 100644
index 00000000000..ac9c7edb2d4
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/actions/leave_host.dm
@@ -0,0 +1,51 @@
+/datum/action/cooldown/mob_cooldown/blood_worm/eject
+ name = "Leave Host"
+ desc = "Leave your host, taking their blood with you."
+
+ button_icon_state = "leave_host"
+
+ cooldown_time = 0 SECONDS
+ shared_cooldown = NONE
+
+ click_to_activate = FALSE
+
+ check_flags = NONE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/eject/IsAvailable(feedback)
+ if (!ishuman(owner) && !istype(owner, /mob/living/basic/blood_worm))
+ return FALSE
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/eject/Activate(atom/target)
+ var/mob/living/basic/blood_worm/worm = src.target
+ var/mob/living/carbon/human/host = worm.host
+
+ host.visible_message(
+ message = span_danger("\The [host] collapse[host.p_s()] and start[host.p_s()] shaking violently!"),
+ ignored_mobs = owner
+ )
+
+ to_chat(owner, span_danger("You begin leaving your host..."))
+
+ host.add_traits(list(TRAIT_FLOORED, TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED), REF(src))
+
+ do_jitter_effect(worm)
+
+ for (var/i in 1 to 2)
+ addtimer(CALLBACK(src, PROC_REF(do_jitter_effect), worm), i SECONDS, TIMER_DELETE_ME)
+
+ if (!do_after(owner, 3 SECONDS, worm.host, timed_action_flags = IGNORE_TARGET_LOC_CHANGE | IGNORE_USER_LOC_CHANGE | IGNORE_INCAPACITATED, extra_checks = CALLBACK(src, PROC_REF(make_sure_shit_doesnt_go_wack), worm)))
+ REMOVE_TRAITS_IN(host, REF(src))
+ return
+
+ REMOVE_TRAITS_IN(host, REF(src))
+
+ worm.leave_host()
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/eject/proc/make_sure_shit_doesnt_go_wack(mob/living/basic/blood_worm/worm)
+ return worm.host // Basically, if the worm is somehow removed midway through, the do_after will continue because it ignores the loc change of the worm. This check prevents that.
+
+/datum/action/cooldown/mob_cooldown/blood_worm/eject/proc/do_jitter_effect(mob/living/basic/blood_worm/worm)
+ worm.host?.do_jitter_animation(100)
diff --git a/code/modules/antagonists/blood_worm/actions/leech_blood.dm b/code/modules/antagonists/blood_worm/actions/leech_blood.dm
new file mode 100644
index 00000000000..e7b0360cbcd
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/actions/leech_blood.dm
@@ -0,0 +1,272 @@
+/datum/action/cooldown/mob_cooldown/blood_worm/leech
+ name = "Leech Blood"
+ desc = "Aggressively grab a target with your teeth and leech off of their blood. Also works on reagent containers like blood packs. Leeching will be canceled if you do any other actions."
+
+ button_icon_state = "leech_blood"
+
+ cooldown_time = 5 SECONDS
+ shared_cooldown = NONE
+
+ unset_after_click = FALSE // Unsetting is handled explicitly.
+ click_cd_override = 0 // Click cooldown is also handled explicitly.
+
+ var/leech_rate = 0
+ var/oxyloss_rate = 0
+
+ var/leech_grab_delay = 1 SECONDS
+
+ /// Associative list of all reagent types that are compatible for leeching from reagent containers. Format is "list[reagent_type] = TRUE"
+ var/static/list/compatible_container_reagent_types = list(
+ /datum/reagent/blood = TRUE, // If it's blood, then it works :D
+ /datum/reagent/consumable/liquidelectricity = TRUE, // Rare enough to allow.
+ )
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/IsAvailable(feedback)
+ if (!istype(owner, /mob/living/basic/blood_worm))
+ return FALSE
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/InterceptClickOn(mob/living/clicker, params, atom/target)
+ var/modifiers = params2list(params)
+
+ // Don't block examines, grabs, etc.
+ if (modifiers[SHIFT_CLICK] || modifiers[ALT_CLICK] || modifiers[CTRL_CLICK])
+ return FALSE
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/Activate(atom/target)
+ owner.face_atom(target)
+
+ if (!ismovable(target))
+ return FALSE
+ if (!owner.Adjacent(target))
+ target.balloon_alert(owner, "too far away!")
+ return FALSE
+ if (!target.IsReachableBy(owner))
+ target.balloon_alert(owner, "can't reach!")
+ return FALSE
+
+ // If you fail after this point, it's because your attempt got interrupted or because the victim is invalid.
+ unset_click_ability(owner, refund_cooldown = FALSE)
+
+ if (isliving(target))
+ leech_living(owner, target)
+ else if (is_reagent_container(target))
+ leech_container(owner, target)
+ else
+ target.balloon_alert(owner, "can't leech from this!")
+
+ return TRUE // Prevents biting.
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/leech_living(mob/living/basic/blood_worm/leech, mob/living/target)
+ if (!leech_living_start_check(leech, target))
+ return
+
+ leech.visible_message(
+ message = span_danger("\The [leech] start[leech.p_s()] trying to bite into \the [target]!"),
+ self_message = span_danger("You start trying to bite into \the [target]!"),
+ ignored_mobs = list(target)
+ )
+
+ target.show_message(
+ msg = span_userdanger("\The [leech] start[leech.p_s()] trying to bite into you!"),
+ type = MSG_VISUAL
+ )
+
+ leech.changeNext_move(CLICK_CD_CLICK_ABILITY)
+
+ if (!do_after(leech, leech_grab_delay, target, extra_checks = CALLBACK(src, PROC_REF(leech_living_start_check), leech, target)))
+ return
+
+ if (leech.pulling != target && !leech.grab(target))
+ target.balloon_alert(leech, "unable to grab!")
+ return
+ if (leech.grab_state < GRAB_AGGRESSIVE)
+ leech.setGrabState(GRAB_AGGRESSIVE)
+
+ // Flooring the target makes them treat our grab state as one higher, halving their escape chance.
+ // Using a neck grab would reduce their escape chance to a third, which is too low.
+ ADD_TRAIT(target, TRAIT_FLOORED, REF(src))
+
+ // Prevents NPCs from resisting out of this.
+ if (!target.client)
+ incapacitate_leech_living_target(target)
+
+ // Remove incapacitation if a client logs into the target, and readd it if they log back out.
+ RegisterSignal(target, COMSIG_MOB_CLIENT_LOGIN, PROC_REF(deincapacitate_leech_living_target))
+ RegisterSignal(target, COMSIG_MOB_LOGOUT, PROC_REF(incapacitate_leech_living_target))
+
+ leech.visible_message(
+ message = span_danger("\The [leech] bite[leech.p_s()] into \the [target]!"),
+ self_message = span_danger("You bite into \the [target]!"),
+ blind_message = span_hear("You hear a bite, followed by a sickening crunch!"),
+ ignored_mobs = list(target)
+ )
+
+ target.show_message(
+ msg = span_userdanger("\The [leech] bite[leech.p_s()] into you!"),
+ type = MSG_VISUAL,
+ alt_msg = span_userdanger("You feel something bite into you!"),
+ alt_type = MSG_AUDIBLE
+ )
+
+ playsound(target, 'sound/items/weapons/bite.ogg', vol = 80, vary = TRUE, ignore_walls = FALSE)
+ playsound(target, 'sound/effects/wounds/pierce3.ogg', vol = 100, vary = TRUE, ignore_walls = FALSE)
+
+ var/synth_content = target.get_blood_synth_content()
+ if (synth_content >= 1)
+ target.balloon_alert(leech, "fully synthetic")
+ else if (synth_content > 0)
+ target.balloon_alert(leech, "[CEILING(synth_content * 100, 1)]% synthetic")
+
+ // Because of DO_AFTER_CHECK_NEXT_MOVE
+ leech.next_move = 0
+
+ while (do_after(leech, 1 SECONDS, target, timed_action_flags = IGNORE_USER_LOC_CHANGE | IGNORE_TARGET_LOC_CHANGE | DO_AFTER_CHECK_NEXT_MOVE, extra_checks = CALLBACK(src, PROC_REF(leech_living_active_check), leech, target)))
+ leech.consume_blood(-target.adjust_blood_volume(-leech_rate), target.get_blood_synth_content())
+
+ if (target.stat != DEAD)
+ target.adjust_oxy_loss(oxyloss_rate) // It's really weird if they just stand there until they literally drop dead from going below BLOOD_VOLUME_SURVIVE.
+
+ playsound(target, 'sound/effects/wounds/splatter.ogg', vol = 80, vary = TRUE, ignore_walls = FALSE)
+
+ if (leech.pulling == target && leech.grab_state >= GRAB_AGGRESSIVE)
+ leech.setGrabState(GRAB_PASSIVE)
+
+ UnregisterSignal(target, list(COMSIG_MOB_CLIENT_LOGIN, COMSIG_MOB_LOGOUT))
+
+ REMOVE_TRAITS_IN(target, REF(src))
+
+ StartCooldown()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/leech_living_start_check(mob/living/basic/blood_worm/leech, mob/living/target)
+ if (target.get_blood_volume() <= 0)
+ target.balloon_alert(leech, "no blood!")
+ return FALSE
+ if (HAS_TRAIT(target, TRAIT_BLOOD_WORM_HOST))
+ target.balloon_alert(leech, "occupied by our kin!")
+ return FALSE
+ return TRUE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/leech_living_active_check(mob/living/basic/blood_worm/leech, mob/living/target)
+ if (target.get_blood_volume() <= 0)
+ target.balloon_alert(leech, "no more blood!")
+ return FALSE
+ if (HAS_TRAIT(target, TRAIT_BLOOD_WORM_HOST))
+ target.balloon_alert(leech, "occupied by our kin!")
+ return FALSE
+ if (!leech.Adjacent(target) || leech.pulling != target || leech.grab_state < GRAB_AGGRESSIVE)
+ target.balloon_alert(leech, "grab lost!")
+ return FALSE
+ return TRUE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/incapacitate_leech_living_target(mob/living/target)
+ SIGNAL_HANDLER
+ ADD_TRAIT(target, TRAIT_INCAPACITATED, REF(src))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/deincapacitate_leech_living_target(mob/living/target)
+ SIGNAL_HANDLER
+ REMOVE_TRAIT(target, TRAIT_INCAPACITATED, REF(src))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/leech_container(mob/living/basic/blood_worm/leech, obj/item/reagent_containers/target)
+ if (!leech_container_start_check(leech, target, feedback = TRUE))
+ return
+
+ leech.visible_message(
+ message = span_danger("\The [leech] start[leech.p_s()] trying to bite into \the [target]!"),
+ self_message = span_danger("You start trying to bite into \the [target]!")
+ )
+
+ leech.changeNext_move(CLICK_CD_CLICK_ABILITY)
+
+ if (!do_after(leech, leech_grab_delay, target, extra_checks = CALLBACK(src, PROC_REF(leech_container_start_check), leech, target)))
+ return
+
+ leech.visible_message(
+ message = span_danger("\The [leech] bite[leech.p_s()] into \the [target]!"),
+ self_message = span_danger("You bite into \the [target]!"),
+ blind_message = span_hear("You hear a bite!"),
+ ignored_mobs = list(target)
+ )
+
+ playsound(target, 'sound/items/weapons/bite.ogg', vol = 80, vary = TRUE, ignore_walls = FALSE)
+
+ leech_container_alert_synth_info(leech, target)
+
+ // Because of DO_AFTER_CHECK_NEXT_MOVE
+ leech.next_move = 0
+
+ while (do_after(leech, 1 SECONDS, target, timed_action_flags = DO_AFTER_CHECK_NEXT_MOVE, extra_checks = CALLBACK(src, PROC_REF(leech_container_active_check), leech, target)))
+ var/list/blood = get_blood_in_container(target)
+
+ var/total_volume = 0
+ for (var/datum/reagent/reagent as anything in blood)
+ total_volume += reagent.volume
+
+ for (var/datum/reagent/reagent as anything in blood)
+ var/synth_content = reagent.data?[BLOOD_DATA_SYNTH_CONTENT]
+ var/amount_consumed = target.reagents.remove_reagent(reagent.type, leech_rate * (reagent.volume / total_volume))
+
+ leech.consume_blood(amount_consumed, synth_content)
+
+ playsound(target, 'sound/effects/wounds/splatter.ogg', vol = 80, vary = TRUE, ignore_walls = FALSE)
+
+ StartCooldown()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/leech_container_alert_synth_info(mob/living/basic/blood_worm/leech, obj/item/reagent_containers/target)
+ var/list/blood = get_blood_in_container(target)
+
+ var/total_volume = 0
+ var/synth_content = 0
+
+ for (var/datum/reagent/reagent as anything in blood)
+ total_volume += reagent.volume
+ synth_content += reagent.data?[BLOOD_DATA_SYNTH_CONTENT] * reagent.volume
+
+ synth_content /= total_volume
+
+ if (synth_content >= 1)
+ target.balloon_alert(leech, "fully synthetic")
+ else if (synth_content > 0)
+ target.balloon_alert(leech, "[CEILING(synth_content * 100, 1)]% synthetic")
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/leech_container_start_check(mob/living/basic/blood_worm/leech, obj/item/reagent_containers/target, feedback = FALSE)
+ if (!length(get_blood_in_container(target)))
+ if (feedback)
+ target.balloon_alert(leech, "no blood!")
+ return FALSE
+ return TRUE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/leech_container_active_check(mob/living/basic/blood_worm/leech, obj/item/reagent_containers/target)
+ if (!length(get_blood_in_container(target)))
+ target.balloon_alert(leech, "no more blood!")
+ return FALSE
+ return TRUE
+
+/// Returns all of the blood in the given container. Format is "list[reagent_type] = volume"
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/proc/get_blood_in_container(obj/item/reagent_containers/target)
+ . = list()
+
+ if (target.reagents.total_volume <= 0)
+ return
+
+ for (var/datum/reagent/reagent as anything in target.reagents.reagent_list)
+ if (reagent.volume <= 0)
+ continue
+ if (!compatible_container_reagent_types[reagent.type])
+ continue
+
+ . += reagent
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/hatchling
+ leech_rate = BLOOD_VOLUME_NORMAL * 0.05 // 28 units of blood, 5 points of health, or 6.25% of a hatchling blood worm's health
+ oxyloss_rate = 11 // crosses from 44 to 55 at 5 seconds (50 is unconscious)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/juvenile
+ leech_rate = BLOOD_VOLUME_NORMAL * 0.075 // 42 units of blood, 7.5 points of health, or 6.25% of a juvenile blood worm's health
+ oxyloss_rate = 15 // crosses from 45 to 60 at 4 seconds (50 is unconscious)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/leech/adult
+ leech_rate = BLOOD_VOLUME_NORMAL * 0.1 // 56 units of blood, 10 points of health, or 5.55% of an adult blood worm's health
+ oxyloss_rate = 20 // crosses from 40 to 60 at 3 seconds (50 is unconscious)
diff --git a/code/modules/antagonists/blood_worm/actions/revive_host.dm b/code/modules/antagonists/blood_worm/actions/revive_host.dm
new file mode 100644
index 00000000000..a9aa47555f6
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/actions/revive_host.dm
@@ -0,0 +1,105 @@
+/datum/action/cooldown/mob_cooldown/blood_worm/revive
+ name = "Revive Host"
+ desc = "Restart the blood circulation of your host, bringing them back to life."
+
+ button_icon_state = "revive_host"
+
+ cooldown_time = 30 SECONDS
+ shared_cooldown = NONE
+
+ click_to_activate = FALSE
+
+ check_flags = NONE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/revive/Grant(mob/granted_to)
+ . = ..()
+
+ var/mob/living/basic/blood_worm/worm = target
+ var/mob/living/carbon/human/host = worm.host
+
+ RegisterSignals(host, list(COMSIG_MOB_STATCHANGE, COMSIG_LIVING_HEALTH_UPDATE), PROC_REF(update_status_on_signal))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/revive/Remove(mob/removed_from)
+ var/mob/living/basic/blood_worm/worm = target
+ var/mob/living/carbon/human/host = worm.host
+
+ UnregisterSignal(host, list(COMSIG_MOB_STATCHANGE, COMSIG_LIVING_HEALTH_UPDATE))
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/revive/IsAvailable(feedback)
+ if (!ishuman(owner) && !istype(owner, /mob/living/basic/blood_worm))
+ return FALSE
+
+ var/mob/living/basic/blood_worm/worm = target
+ var/mob/living/carbon/human/host = worm.host
+
+ if (!run_checks(worm, host, feedback))
+ return FALSE
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/revive/Activate(atom/target)
+ var/mob/living/basic/blood_worm/worm = target
+ var/mob/living/carbon/human/host = worm.host
+
+ to_chat(owner, span_notice("You begin restarting \the [host]'s blood circulation..."))
+
+ for (var/i in 1 to 3)
+ if (!do_after(owner, 2 SECONDS, host, timed_action_flags = IGNORE_INCAPACITATED | IGNORE_USER_LOC_CHANGE | IGNORE_TARGET_LOC_CHANGE, extra_checks = CALLBACK(src, PROC_REF(run_checks), worm, host)))
+ return FALSE
+
+ REMOVE_TRAIT(worm, TRAIT_DEAF, BLOOD_WORM_HOST_TRAIT)
+ playsound(host, 'sound/effects/singlebeat.ogg', vol = 50, vary = TRUE, ignore_walls = FALSE)
+ ADD_TRAIT(worm, TRAIT_DEAF, BLOOD_WORM_HOST_TRAIT)
+
+ var/original_transform = host.transform
+ animate(host, transform = host.transform.Translate(0, 3), time = 0.2 SECONDS, easing = CUBIC_EASING | EASE_OUT, flags = ANIMATION_PARALLEL)
+ animate(transform = original_transform, time = 0.2 SECONDS, easing = CUBIC_EASING | EASE_IN, flags = ANIMATION_PARALLEL)
+
+ host.visible_message(
+ message = span_danger("\The [host] shake[host.p_s()] violently!"),
+ ignored_mobs = owner
+ )
+
+ if (!host.revive())
+ host.balloon_alert(owner, "revival failed!")
+ return FALSE
+
+ host.visible_message(
+ message = span_danger("\The [host] rise[host.p_s()] from the dead!"),
+ ignored_mobs = owner
+ )
+
+ to_chat(owner, span_green("You successfully revive \the [host]!"))
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/revive/proc/run_checks(mob/living/basic/blood_worm/worm, mob/living/carbon/human/host, feedback = FALSE)
+ if (!worm.host)
+ return FALSE
+ if (host.stat != DEAD)
+ if (feedback)
+ host.balloon_alert(owner, "not dead!")
+ return FALSE
+ if (HAS_TRAIT(host, TRAIT_HUSK))
+ if (feedback)
+ host.balloon_alert(owner, "husked!")
+ return FALSE
+ if (!host.get_organ_slot(ORGAN_SLOT_BRAIN))
+ if (feedback)
+ host.balloon_alert(owner, "no brain!")
+ return FALSE
+ if (host.get_organ_loss(ORGAN_SLOT_BRAIN) >= BRAIN_DAMAGE_DEATH && !HAS_TRAIT(host, TRAIT_BRAIN_DAMAGE_NODEATH))
+ if (feedback)
+ host.balloon_alert(owner, "brain too damaged!")
+ return FALSE
+ if (host.health <= HEALTH_THRESHOLD_DEAD)
+ if (feedback)
+ host.balloon_alert(owner, "body too damaged!")
+ return FALSE
+ if (!host.can_be_revived()) // Fallback, ideally caught by earlier, more descriptive checks.
+ if (feedback)
+ host.balloon_alert(owner, "unable to revive!")
+ return FALSE
+ return TRUE
diff --git a/code/modules/antagonists/blood_worm/actions/spit_blood.dm b/code/modules/antagonists/blood_worm/actions/spit_blood.dm
new file mode 100644
index 00000000000..fc765c00906
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/actions/spit_blood.dm
@@ -0,0 +1,282 @@
+/datum/action/cooldown/mob_cooldown/blood_worm/spit
+ name = "Spit Blood"
+ desc = "Spit corrosive blood at your target in exchange for your own health. Right-click to melt restraints while in a host."
+
+ button_icon_state = "spit_blood"
+
+ cooldown_time = 0 SECONDS
+ shared_cooldown = NONE
+
+ unset_after_click = FALSE // Unsetting is handled explicitly.
+
+ var/health_cost = 0
+ var/minimum_health = 10
+
+ var/projectile_type = null
+ var/burst_projectile_type = null
+ var/burst_count = 5
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/New(Target, original)
+ . = ..()
+ RegisterSignal(target, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(update_status_on_signal))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/Destroy()
+ UnregisterSignal(target, COMSIG_LIVING_HEALTH_UPDATE)
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/set_click_ability(mob/on_who)
+ . = ..()
+ var/right_click_message = ishuman(owner) ? ", right-click to melt restraints" : (burst_projectile_type ? ", right-click for a burst" : "")
+ to_chat(owner, span_notice("You fill your [ishuman(owner) ? "mouth" : "maw"] with blood. Left-click to spit corrosive blood[right_click_message]!"))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/unset_click_ability(mob/on_who, refund_cooldown)
+ . = ..()
+ to_chat(owner, span_notice("You empty your [ishuman(owner) ? "mouth" : "maw"] of blood."))
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/IsAvailable(feedback)
+ if (!ishuman(owner) && !istype(owner, /mob/living/basic/blood_worm))
+ return FALSE
+
+ var/mob/living/basic/blood_worm/worm = target
+
+ if (worm.host?.is_mouth_covered())
+ if (feedback)
+ owner.balloon_alert(owner, "mouth is covered!")
+ return FALSE
+ if (worm.get_worm_health() - health_cost < minimum_health)
+ if (feedback)
+ owner.balloon_alert(owner, "out of blood!")
+ return FALSE
+
+ return ..()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/InterceptClickOn(mob/living/clicker, params, atom/target)
+ . = ..()
+ if (!.)
+ unset_click_ability(owner, refund_cooldown = FALSE)
+ return FALSE
+
+ var/modifiers = params2list(params)
+
+ // Don't block examines, grabs, etc.
+ if (modifiers[SHIFT_CLICK] || modifiers[ALT_CLICK] || modifiers[CTRL_CLICK])
+ return FALSE
+
+ owner.face_atom(target)
+
+ var/mob/living/basic/blood_worm/worm = src.target
+
+ if (modifiers[RIGHT_CLICK] && worm.host)
+ melt_restraints()
+ else if (modifiers[RIGHT_CLICK] && burst_projectile_type)
+ fire_burst(clicker, modifiers, target)
+ else
+ fire_normal(clicker, modifiers, target)
+
+ if (!IsAvailable(feedback = FALSE))
+ unset_click_ability(owner, refund_cooldown = FALSE)
+
+ return TRUE // Intercepts the attack chain.
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/Activate(atom/target)
+ return TRUE // Has to return true, as otherwise the parent proc of InterceptClickOn will return false, canceling the firing of the projectile.
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/fire_normal(mob/living/clicker, modifiers, atom/target)
+ if (target == owner)
+ return
+
+ owner.visible_message(
+ message = span_danger("\The [owner] spit[owner.p_s()] corrosive blood!"),
+ self_message = span_danger("You spit corrosive blood!"),
+ blind_message = span_hear("You hear spitting.")
+ )
+
+ spit(target, modifiers, projectile_type)
+
+ playsound(owner, SFX_ALIEN_SPIT_ACID, vol = 25, vary = TRUE)
+
+ owner.changeNext_move(CLICK_CD_RANGE)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/fire_burst(mob/living/clicker, modifiers, atom/target)
+ if (target == owner)
+ return
+
+ var/mob/living/basic/blood_worm/worm = src.target
+ if (worm.get_worm_health() - health_cost * burst_count < minimum_health)
+ owner.balloon_alert(owner, "out of blood!")
+ return
+
+ owner.visible_message(
+ message = span_danger("\The [owner] spit[owner.p_s()] a burst of corrosive blood!"),
+ self_message = span_danger("You spit a burst of corrosive blood!"),
+ blind_message = span_hear("You hear spitting.")
+ )
+
+ spit(target, modifiers, burst_projectile_type, count = burst_count, spread = 10)
+
+ playsound(owner, SFX_ALIEN_SPIT_ACID, vol = 40, vary = TRUE)
+ playsound(owner, 'sound/mobs/non-humanoids/bileworm/bileworm_spit.ogg', vol = 40, vary = TRUE)
+
+ owner.changeNext_move(CLICK_CD_RANGE)
+ StartCooldown(10 SECONDS)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/spit(target, modifiers, projectile_type, count = 1, spread = 0)
+ for (var/i in 1 to count)
+ var/obj/projectile/blood_worm_spit/spit = new projectile_type(owner.loc)
+
+ spit.firer = owner
+ spit.fired_from = owner
+ spit.aim_projectile(target, owner, modifiers, deviation = count == 1 ? 0 : lerp(-spread, spread, (i - 1) / (count - 1)))
+ spit.fire()
+
+ owner.newtonian_move(get_angle(target, owner), instant = TRUE, drift_force = count)
+
+ var/mob/living/basic/blood_worm/worm = src.target
+
+ worm.adjust_worm_health(-health_cost * count)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/melt_restraints()
+ var/mob/living/carbon/human/host = owner
+
+ var/melted_something = FALSE
+ var/something_to_melt = FALSE
+
+ if (host.handcuffed)
+ something_to_melt = TRUE
+ melted_something |= melt_restraints_in_slot(host, ITEM_SLOT_HANDCUFFED)
+ if (host.legcuffed)
+ something_to_melt = TRUE
+ melted_something |= melt_restraints_in_slot(host, ITEM_SLOT_LEGCUFFED)
+ if (host.wear_suit?.breakouttime)
+ something_to_melt = TRUE
+ melted_something |= melt_restraints_in_slot(host, ITEM_SLOT_OCLOTHING)
+ if (host.shoes?.tied == SHOES_KNOTTED)
+ something_to_melt = TRUE
+ melted_something |= melt_restraints_in_slot(host, ITEM_SLOT_FEET)
+ if (istype(host.loc, /obj/structure/closet))
+ something_to_melt = TRUE
+ melted_something |= melt_closet(host, host.loc)
+ if (istype(host.loc, /obj/structure/spider/cocoon))
+ something_to_melt = TRUE
+ melted_something |= melt_cocoon(host, host.loc)
+
+ if (melted_something)
+ playsound(host, SFX_SIZZLE, vol = 80, vary = TRUE, ignore_walls = FALSE)
+ StartCooldown(20 SECONDS)
+ if (!something_to_melt)
+ host.balloon_alert(host, "not restrained!")
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/melt_restraints_in_slot(mob/living/carbon/human/host, slot)
+ var/obj/restraints = host.get_item_by_slot(slot)
+
+ if (!istype(restraints))
+ return FALSE
+ if (restraints.resistance_flags & (INDESTRUCTIBLE | UNACIDABLE | ACID_PROOF))
+ host.balloon_alert(host, "\the [restraints] [restraints.p_are()] too tough!")
+ return FALSE
+
+ host.visible_message(
+ message = span_danger("\The [host] spit[host.p_s()] corrosive blood all over \the [restraints]!"),
+ self_message = span_danger("You spit corrosive blood all over \the [restraints]!"),
+ blind_message = span_hear("You hear sizzling.")
+ )
+
+ log_combat(host, restraints, "melted", addition = "(Spit Blood)")
+
+ addtimer(CALLBACK(src, PROC_REF(finish_melting_restraints), restraints), 5 SECONDS)
+ return TRUE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/finish_melting_restraints(obj/restraints)
+ restraints.visible_message(span_danger("\The [restraints] melt[restraints.p_s()] into a pile of goopy blood!"))
+ new /obj/effect/decal/cleanable/blood/old(get_turf(restraints))
+ qdel(restraints)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/melt_closet(mob/living/carbon/human/host, obj/structure/closet/closet)
+ if (closet.resistance_flags & (INDESTRUCTIBLE | UNACIDABLE | ACID_PROOF))
+ host.balloon_alert(host, "\the [closet] [closet.p_are()] too tough!")
+ return FALSE
+
+ closet.visible_message(
+ message = span_danger("\The [closet]'s hinges overflow with corrosive blood and begin to melt!"),
+ blind_message = span_hear("You hear sizzling."),
+ ignored_mobs = host
+ )
+
+ to_chat(host, span_danger("You spit corrosive blood all over \the [closet]'s interior hinges!"))
+
+ log_combat(host, closet, "melted", addition = "(Spit Blood)")
+
+ addtimer(CALLBACK(src, PROC_REF(finish_melting_closet), closet), 5 SECONDS)
+ return TRUE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/finish_melting_closet(obj/structure/closet/closet)
+ closet.visible_message(span_danger("\The [closet]'s hinges melt into a pile of goopy blood!"))
+ new /obj/effect/decal/cleanable/blood/old(get_turf(closet))
+
+ closet.welded = FALSE
+ closet.locked = FALSE
+ closet.broken = TRUE
+ closet.open()
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/melt_cocoon(mob/living/carbon/human/host, obj/structure/spider/cocoon/cocoon)
+ if (cocoon.resistance_flags & (INDESTRUCTIBLE | UNACIDABLE | ACID_PROOF))
+ host.balloon_alert(host, "\the [cocoon] [cocoon.p_are()] too tough!")
+ return FALSE
+
+ cocoon.visible_message(
+ message = span_danger("\The [cocoon]'s threads begin to fall apart!"),
+ blind_message = span_hear("You hear sizzling."),
+ ignored_mobs = host
+ )
+
+ to_chat(host, span_danger("You spit corrosive blood all over the inside of \the [cocoon]!"))
+
+ log_combat(host, cocoon, "melted", addition = "(Spit Blood)")
+
+ addtimer(CALLBACK(src, PROC_REF(finish_melting_cocoon), cocoon), 5 SECONDS)
+ return TRUE
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/proc/finish_melting_cocoon(obj/structure/spider/cocoon/cocoon)
+ cocoon.visible_message(span_danger("\The [cocoon] melt[cocoon.p_s()] into a pile of goopy blood!"))
+ new /obj/effect/decal/cleanable/blood/old(get_turf(cocoon))
+ qdel(cocoon)
+
+/obj/projectile/blood_worm_spit
+ name = "corrosive blood spit"
+ icon_state = "blood_spit"
+
+ damage_type = BURN
+ armor_flag = BULLET // I'm sorry. Acid armor is too nonsensical for combat, as its granted based on how easily acid should destroy objects.
+
+ hitsound = 'sound/items/weapons/sear.ogg'
+ hitsound_wall = 'sound/items/weapons/sear.ogg'
+
+ impact_effect_type = /obj/effect/temp_visual/impact_effect/blood_worm_spit
+
+/obj/effect/temp_visual/impact_effect/blood_worm_spit
+ color = "#ff1313"
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/juvenile
+ health_cost = 5.5 // This is enough for 20 shots in a row at full health. (keep in mind that health is VERY important)
+ projectile_type = /obj/projectile/blood_worm_spit/juvenile
+
+/obj/projectile/blood_worm_spit/juvenile
+ damage = 20 // 300 damage total, assuming no armor.
+ armour_penetration = 30 // So that sec cant just nullify half the kit of the blood worms with bulletproof armor.
+ wound_bonus = 0 // Juveniles can afford to fix wounds on their hosts. This doesn't cause critical wounds. (at least not in testing)
+
+/datum/action/cooldown/mob_cooldown/blood_worm/spit/adult
+ desc = "Spit corrosive blood at your target in exchange for your own health. Right-click to melt restraints while in a host, or fire a burst while out of a host."
+ health_cost = 6.5 // This is enough for 26 shots in a row at full health. (keep in mind that health is VERY important)
+ projectile_type = /obj/projectile/blood_worm_spit/adult
+ burst_projectile_type = /obj/projectile/blood_worm_spit/adult_burst
+
+/obj/projectile/blood_worm_spit/adult
+ damage = 25 // 500 damage total, assuming no armor.
+ armour_penetration = 50 // Yeah no your armor isn't saving you from this.
+ wound_bonus = 5 // Okay, now we're talking.
+
+/obj/projectile/blood_worm_spit/adult_burst
+ damage = 15 // 75 damage per burst if all hit.
+ armour_penetration = 50 // Same armor penetration.
+ wound_bonus = 2 // Slightly less wound power than normal.
diff --git a/code/modules/antagonists/blood_worm/blood_worm_antag.dm b/code/modules/antagonists/blood_worm/blood_worm_antag.dm
new file mode 100644
index 00000000000..fc8206ffdfd
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/blood_worm_antag.dm
@@ -0,0 +1,101 @@
+/datum/antagonist/blood_worm
+ name = "\improper Blood Worm"
+ roundend_category = "blood worms"
+ antagpanel_category = ANTAG_GROUP_BLOODY
+ pref_flag = ROLE_BLOOD_WORM
+ antag_hud_name = "blood_worm"
+ antag_moodlet = /datum/mood_event/blood_worm
+ hijack_speed = 0.5
+ show_to_ghosts = TRUE // I mean let's be honest, even the roundstart versions will become painfully apparent soon enough.
+ stinger_sound = 'sound/effects/magic/exit_blood.ogg'
+ ui_name = "AntagInfoBloodWorm"
+
+ var/datum/team/blood_worm/team = null
+
+/datum/antagonist/blood_worm/Destroy()
+ team = null
+ return ..()
+
+/datum/antagonist/blood_worm/greet()
+ . = ..()
+
+ to_chat(owner, span_bold("A species of space-faring leech, massive in size and ferocious in hunting. \
+ Your origins are unknown to most, but to some, you are among their greatest creations. \
+ A failed Syndicate bioweapons project, snuffed out by benefactors after a \"lack of results\", and yet... \
+ here you find yourself. On a Nanotrasen space station. What a fitting habitat for you, isn't it?"))
+ to_chat(owner, span_bolddanger("KILL, CONSUME, MULTIPLY, CONQUER."))
+
+ owner.announce_objectives()
+
+/datum/antagonist/blood_worm/forge_objectives()
+ objectives |= team.objectives
+
+/datum/antagonist/blood_worm/create_team(datum/team/new_team)
+ GLOB.blood_worm_team ||= new()
+ team = GLOB.blood_worm_team
+
+/datum/antagonist/blood_worm/get_team()
+ return team
+
+/datum/antagonist/blood_worm/on_gain()
+ forge_objectives()
+ ADD_TRAIT(owner, TRAIT_UNCONVERTABLE, REF(src)) // No blood cultist worms or whatever the fuck
+ return ..()
+
+/datum/antagonist/blood_worm/on_removal()
+ REMOVE_TRAITS_IN(owner, REF(src))
+ return ..()
+
+/datum/antagonist/blood_worm/apply_innate_effects(mob/living/mob_override)
+ var/mob/living/target = mob_override || owner.current
+
+ add_team_hud(target)
+
+ if (!istype(target, /mob/living/basic/blood_worm))
+ target.faction |= FACTION_BLOOD_WORM
+
+ // Apathy and fearlessness are traits inherent to the very mind of a blood worm.
+ // Being immune to hunger, withdrawals, etc. are physical traits of blood worm hosts.
+ target.add_traits(list(TRAIT_APATHETIC, TRAIT_FEARLESS), REF(src))
+
+/datum/antagonist/blood_worm/remove_innate_effects(mob/living/mob_override)
+ var/mob/living/target = mob_override || owner.current
+
+ if (!istype(target, /mob/living/basic/blood_worm))
+ target.faction -= FACTION_BLOOD_WORM
+
+ REMOVE_TRAITS_IN(target, REF(src))
+
+/datum/antagonist/blood_worm/admin_add(datum/mind/new_owner, mob/admin)
+ if (!new_owner.current)
+ return
+
+ if (!istype(new_owner.current, /mob/living/basic/blood_worm))
+ var/old_mob = new_owner.current
+
+ var/mob/living/basic/blood_worm/hatchling/new_worm = new(get_turf(new_owner.current))
+ new_owner.transfer_to(new_worm, force_key_move = TRUE)
+
+ qdel(old_mob)
+
+ return ..()
+
+/datum/antagonist/blood_worm/get_preview_icon()
+ var/icon/icon = icon('icons/mob/nonhuman-player/blood_worm_32x32.dmi', "juvenile")
+
+ icon.Crop(1, 1, 32, 32)
+ icon.Shift(SOUTH, 4)
+ icon.Scale(ANTAGONIST_PREVIEW_ICON_SIZE, ANTAGONIST_PREVIEW_ICON_SIZE)
+
+ return icon
+
+/datum/antagonist/blood_worm/ui_static_data(mob/user)
+ . = ..()
+ var/list/team_data = list()
+ team_data["blood_consumed_total"] = floor(team.blood_consumed_total)
+ team_data["times_reproduced_total"] = floor(team.times_reproduced_total)
+ .["team"] = team_data
+
+/datum/antagonist/blood_worm/infestation
+ pref_flag = ROLE_BLOOD_WORM_INFESTATION
+ show_in_antagpanel = FALSE
diff --git a/code/modules/antagonists/blood_worm/blood_worm_blood_consumption.dm b/code/modules/antagonists/blood_worm/blood_worm_blood_consumption.dm
new file mode 100644
index 00000000000..0d36e2202e9
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/blood_worm_blood_consumption.dm
@@ -0,0 +1,38 @@
+// Any procs that work with blood consumption go here.
+// There are quite a few of them, and some of them are lengthy.
+
+/mob/living/basic/blood_worm/proc/consume_blood(blood_amount, synth_content = 0, should_heal = TRUE)
+ if (blood_amount <= 0)
+ return
+
+ synth_content = clamp(synth_content, 0, 1)
+
+ var/was_synth_capped = consumed_synth_blood >= maximum_synth_blood
+ var/was_ready_to_mature = get_consumed_blood() >= cocoon_action?.total_blood_required
+
+ var/added_normal_blood = blood_amount * (1 - synth_content)
+ var/added_synth_blood = blood_amount * synth_content * synth_blood_efficiency
+ var/added_total_blood = added_normal_blood + added_synth_blood
+
+ consumed_synth_blood = min(consumed_synth_blood + added_synth_blood, maximum_synth_blood)
+ consumed_normal_blood += added_normal_blood
+
+ if (!was_synth_capped && consumed_synth_blood >= maximum_synth_blood)
+ balloon_alert_self("synthetic cap reached!")
+
+ if (!was_ready_to_mature && get_consumed_blood() >= cocoon_action?.total_blood_required)
+ balloon_alert_self("ready to mature!")
+
+ if (should_heal)
+ // Synthetic blood works just fine for healing.
+ adjust_worm_health(blood_amount * BLOOD_WORM_BLOOD_TO_HEALTH)
+
+ // This is intentionally passing the synth blood amount without any regard for the synth blood growth cap.
+ SEND_SIGNAL(src, COMSIG_BLOOD_WORM_CONSUMED_BLOOD, added_normal_blood, added_synth_blood, added_total_blood)
+
+/mob/living/basic/blood_worm/proc/reset_consumed_blood()
+ consumed_normal_blood = 0
+ consumed_synth_blood = 0
+
+/mob/living/basic/blood_worm/proc/get_consumed_blood()
+ return consumed_normal_blood + consumed_synth_blood
diff --git a/code/modules/antagonists/blood_worm/blood_worm_host_handling.dm b/code/modules/antagonists/blood_worm/blood_worm_host_handling.dm
new file mode 100644
index 00000000000..67543842cd0
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/blood_worm_host_handling.dm
@@ -0,0 +1,260 @@
+// Any proc that handles host logic and doesn't fit in a more specific file goes here.
+// There are a ton of these and they are pretty tightly coupled to each other.
+
+/mob/living/basic/blood_worm/proc/enter_host(mob/living/carbon/human/new_host, silent = FALSE, gain_progress = TRUE)
+ if (!silent)
+ playsound(src, 'sound/effects/magic/enter_blood.ogg', vol = 60, vary = TRUE, ignore_walls = FALSE)
+
+ visible_message(
+ message = span_bolddanger("\The [src] enter[p_s()] \the [new_host]!"),
+ self_message = span_notice("You enter \the [new_host]."),
+ blind_message = span_hear("You hear a squelch.")
+ )
+
+ new /obj/effect/temp_visual/blood_worm_invade_host(get_turf(new_host), effect_name)
+
+ host = new_host
+
+ RegisterSignal(host, COMSIG_QDELETING, PROC_REF(on_host_qdel))
+ RegisterSignal(host, COMSIG_MOB_STATCHANGE, PROC_REF(on_host_stat_changed))
+ RegisterSignal(host, COMSIG_HUMAN_ON_HANDLE_BLOOD, PROC_REF(on_host_handle_blood))
+ RegisterSignal(host, COMSIG_LIVING_LIFE, PROC_REF(on_host_life))
+ RegisterSignal(host, COMSIG_LIVING_ADJUST_OXY_DAMAGE, PROC_REF(on_host_adjust_oxy_damage))
+ RegisterSignal(host, COMSIG_LIVING_PRE_UPDATE_BLOOD_STATUS, PROC_REF(on_host_pre_update_blood_status))
+ RegisterSignal(host, COMSIG_MOB_GET_STATUS_TAB_ITEMS, PROC_REF(on_host_get_status_tab_items))
+ RegisterSignal(host, COMSIG_MOB_EXAMINING, PROC_REF(on_host_examining))
+
+ START_PROCESSING(SSfastprocess, src)
+
+ add_traits(list(TRAIT_INCAPACITATED, TRAIT_IMMOBILIZED, TRAIT_MUTE, TRAIT_EMOTEMUTE), BLOOD_WORM_HOST_TRAIT)
+
+ host.add_traits(list(
+ TRAIT_NOBREATH, // Makes blood worms carry at least one of their atmos immunities to a host. Also allows them to take off masks to be able to spit, without risking suffocation.
+ TRAIT_STABLEHEART, // Allows blood worms to take fucked up hosts. Especially useful if you accidentally gut your future host yourself. (very possible as an adult)
+ TRAIT_STABLELIVER, // Ditto.
+ TRAIT_NOCRITDAMAGE, // Prevents blood worms from noob trapping themselves by reviving a host "too early", since that shouldn't be a thing.
+ TRAIT_NOHUNGER, // Every single long-deceased corpse is starved. Many people also just ignore hunger the whole shift. Starving with every new host gets old fast.
+ TRAIT_NO_WITHDRAWALS, // Prevents OOC quirk choices from impacting blood worms as much. Stops withdrawals instead of addictions since the latter can be metagamed.
+ TRAIT_NO_SPLIT_PERSONALITY, // How about no?
+ TRAIT_BLOOD_HUD, // Self-explanatory, allows blood worms to seek prey even while in a host.
+ TRAIT_BLOOD_WORM_HOST), // Used in code for recognizing blood worm hosts with a simple trait check.
+ BLOOD_WORM_HOST_TRAIT)
+
+ if (client)
+ ADD_TRAIT(host, TRAIT_MIND_TEMPORARILY_GONE, BLOOD_WORM_HOST_TRAIT)
+
+ host.physiology.bleed_mod *= BLOOD_WORM_BLEED_MOD
+
+ host.AddElement(/datum/element/hand_organ_insertion)
+
+ remove_actions(src, innate_actions)
+ grant_actions(src, host_actions)
+
+ var/cached_blood_volume = host.get_blood_volume()
+
+ if (gain_progress)
+ // Apply the host's blood volume to our growth.
+ consume_blood(cached_blood_volume, host.get_blood_synth_content(), should_heal = FALSE)
+
+ // Combine our health with the blood of our host.
+ host.set_blood_volume(cached_blood_volume + health * BLOOD_WORM_HEALTH_TO_BLOOD)
+
+ // Modify host blood such that it's BLOOD_VOLUME_NORMAL when we're at max health.
+ host.set_blood_volume_modifier(REF(src), BLOOD_VOLUME_NORMAL / (maxHealth * BLOOD_WORM_HEALTH_TO_BLOOD))
+
+ // Caps host blood volume to our custom maximum and syncs our bruteloss with host health.
+ sync_health()
+
+ if (host.hud_used)
+ create_host_hud(host)
+ else
+ RegisterSignal(host, COMSIG_MOB_HUD_CREATED, PROC_REF(create_host_hud))
+
+ forceMove(host)
+
+ log_blood_worm("[key_name(src)] entered their new host [key_name(host)]")
+
+ if (host.stat != DEAD)
+ possess_host()
+
+/mob/living/basic/blood_worm/proc/leave_host()
+ if (!host)
+ return
+
+ visible_message(
+ message = span_bolddanger("\The [src] emerge[p_s()] from \the [host]!"),
+ blind_message = span_hear("You hear a squelch."),
+ ignored_mobs = list(host, src)
+ )
+
+ to_chat_self(span_notice("You emerge from \the [host]."))
+
+ log_blood_worm("[key_name(src)] left their host [key_name(host)]")
+
+ forceMove(host.drop_location()) // This will call unregister_host() via Moved()
+
+ playsound(src, 'sound/effects/magic/exit_blood.ogg', vol = 60, vary = TRUE, ignore_walls = FALSE)
+
+ Immobilize(leave_host_duration, ignore_canstun = TRUE)
+ incapacitate(leave_host_duration, ignore_canstun = TRUE)
+
+ // Uses the icon file of the current mob. This means the animation is 32x48 for the adults.
+ flick("leave-[effect_name]", src)
+
+/mob/living/basic/blood_worm/proc/unregister_host()
+ if (!host)
+ return
+
+ possess_worm()
+
+ UnregisterSignal(host, list(
+ COMSIG_QDELETING,
+ COMSIG_MOB_STATCHANGE,
+ COMSIG_HUMAN_ON_HANDLE_BLOOD,
+ COMSIG_LIVING_LIFE,
+ COMSIG_LIVING_ADJUST_OXY_DAMAGE,
+ COMSIG_LIVING_PRE_UPDATE_BLOOD_STATUS,
+ COMSIG_MOB_GET_STATUS_TAB_ITEMS,
+ COMSIG_MOB_EXAMINING,
+ COMSIG_MOB_HUD_CREATED,
+ ))
+
+ STOP_PROCESSING(SSfastprocess, src)
+
+ REMOVE_TRAITS_IN(src, BLOOD_WORM_HOST_TRAIT)
+ REMOVE_TRAITS_IN(host, BLOOD_WORM_HOST_TRAIT)
+ host.physiology.bleed_mod /= BLOOD_WORM_BLEED_MOD
+ host.RemoveElement(/datum/element/hand_organ_insertion)
+
+ remove_actions(src, host_actions)
+ grant_actions(src, innate_actions)
+
+ host.remove_blood_volume_modifier(REF(src))
+ sync_health(already_ejecting = TRUE)
+
+ remove_host_hud()
+
+ host.set_blood_volume(0)
+
+ if (host.stat != DEAD)
+ host.death() // I don't care if you have TRAIT_NODEATH, can't die from bloodloss normally, or whatever else. I just need you to die.
+
+ log_blood_worm("[key_name(src)] unregistered their host [key_name(host)]")
+
+ host = null
+
+/mob/living/basic/blood_worm/proc/possess_host()
+ if (!host || is_possessing_host)
+ return
+
+ is_possessing_host = TRUE
+
+ if (host.mind)
+ backseat = new(host)
+ backseat.death(gibbed = TRUE) // Same thing that the corpse mob spawners do to stop deathgasps and such.
+
+ // If the host is a changeling, then we forcibly move their client to the backseat so they can use Expel Worm if they wish to.
+ host.mind.transfer_to(backseat, force_key_move = host.mind.has_antag_datum(/datum/antagonist/changeling))
+
+ mind?.transfer_to(host)
+
+ remove_actions(src, host_actions)
+ grant_actions(host, host_actions)
+
+ host.grant_language(/datum/language/wormspeak, UNDERSTOOD_LANGUAGE, LANGUAGE_BLOOD_WORM)
+
+ log_blood_worm("[key_name(src)] possessed their host [key_name(host)]")
+
+/mob/living/basic/blood_worm/proc/possess_worm()
+ if (!host || !is_possessing_host)
+ return
+
+ is_possessing_host = FALSE
+
+ host.mind?.transfer_to(src)
+
+ if (backseat)
+ backseat.mind?.transfer_to(host)
+ QDEL_NULL(backseat)
+
+ remove_actions(host, host_actions)
+ grant_actions(src, host_actions)
+
+ host.remove_language(/datum/language/wormspeak, UNDERSTOOD_LANGUAGE, LANGUAGE_BLOOD_WORM)
+
+ log_blood_worm("[key_name(src)] unpossessed their host [key_name(host)]")
+
+/mob/living/basic/blood_worm/proc/on_host_qdel(datum/source, force)
+ SIGNAL_HANDLER
+ qdel(src)
+
+/mob/living/basic/blood_worm/proc/on_host_stat_changed(datum/source, new_stat, old_stat)
+ SIGNAL_HANDLER
+ if (old_stat == DEAD && new_stat != DEAD)
+ possess_host()
+ else if (old_stat != DEAD && new_stat == DEAD)
+ possess_worm()
+
+/mob/living/basic/blood_worm/proc/on_host_handle_blood(datum/source, seconds_per_tick, times_fired)
+ SIGNAL_HANDLER
+ return HANDLE_BLOOD_NO_OXYLOSS | HANDLE_BLOOD_NO_NUTRITION_DRAIN
+
+/mob/living/basic/blood_worm/proc/on_host_life(datum/source, seconds_per_tick, times_fired)
+ // Moved to worm life when not in a host.
+ adjust_worm_health(regen_rate * seconds_per_tick)
+
+ // Required for now, because TRAIT_NOBREATH does not actually prevent oxygen damage.
+ // This is really weird because it also sets oxygen damage to 0 when added to a mob.
+ host.set_oxy_loss(0, forced = TRUE)
+
+ if (!HAS_TRAIT(host, TRAIT_STASIS))
+ handle_host_blood(seconds_per_tick, times_fired)
+ handle_host_temperature(seconds_per_tick, times_fired)
+
+/mob/living/basic/blood_worm/proc/handle_host_blood(seconds_per_tick, times_fired)
+ if (host.stat == DEAD)
+ host.handle_blood(seconds_per_tick, times_fired)
+
+ // Ignored while possessing a host, as [carbon/proc/bleed_warn] handles it instead.
+ if (!is_possessing_host && COOLDOWN_FINISHED(src, host_bleed_alert_cooldown) && host.get_bleed_rate() > 0)
+ to_chat_self(span_userdanger("Your host is bleeding!"))
+ COOLDOWN_START(src, host_bleed_alert_cooldown, 15 SECONDS)
+
+/mob/living/basic/blood_worm/proc/handle_host_temperature(seconds_per_tick, times_fired)
+ if (host.coretemperature <= maximum_survivable_temperature)
+ return
+
+ var/burn_coeff = damage_coeff[BURN]
+ adjust_worm_health(-unsuitable_heat_damage * (burn_coeff ? burn_coeff : 1) * seconds_per_tick)
+
+ if (COOLDOWN_FINISHED(src, host_heat_alert_cooldown))
+ to_chat_self(span_userdanger("Your blood is burning up!"))
+ COOLDOWN_START(src, host_heat_alert_cooldown, 15 SECONDS)
+
+/mob/living/basic/blood_worm/proc/on_host_adjust_oxy_damage(datum/source, type, amount, forced)
+ SIGNAL_HANDLER
+ return COMPONENT_IGNORE_CHANGE // Functionally, this unimplements oxy damage from hosts altogether. Which is exactly what we want.
+
+/mob/living/basic/blood_worm/proc/on_host_pre_update_blood_status(datum/source, had_blood, has_blood, old_blood_volume)
+ SIGNAL_HANDLER
+ if (!has_blood)
+ leave_host()
+
+/mob/living/basic/blood_worm/proc/create_host_hud(datum/source)
+ SIGNAL_HANDLER
+ UnregisterSignal(host, COMSIG_MOB_HUD_CREATED)
+
+ var/datum/hud/hud = host.hud_used
+ blood_display = new(null, hud)
+ hud.infodisplay += blood_display
+ hud.show_hud(hud.hud_version)
+
+/mob/living/basic/blood_worm/proc/remove_host_hud()
+ var/datum/hud/hud = host.hud_used
+
+ if (!hud)
+ QDEL_NULL(blood_display)
+ return
+
+ hud.infodisplay -= blood_display
+ QDEL_NULL(blood_display)
diff --git a/code/modules/antagonists/blood_worm/blood_worm_host_mob.dm b/code/modules/antagonists/blood_worm/blood_worm_host_mob.dm
new file mode 100644
index 00000000000..310f8a0bd94
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/blood_worm_host_mob.dm
@@ -0,0 +1,17 @@
+/mob/living/blood_worm_host
+ name = "Host"
+ desc = "...how are you examining this? THIS THING ISN'T EVEN EMBODIED."
+
+ var/datum/action/changeling_expel_worm/expel_worm_action
+
+/mob/living/blood_worm_host/Login()
+ . = ..()
+ if (!.)
+ return
+
+ if (IS_CHANGELING(src))
+ to_chat(src, span_good("The blood worm in your body is vulnerable to your genetic prowess!"))
+
+ if (!expel_worm_action)
+ expel_worm_action = new(src)
+ expel_worm_action.Grant(src)
diff --git a/code/modules/antagonists/blood_worm/blood_worm_mob.dm b/code/modules/antagonists/blood_worm/blood_worm_mob.dm
new file mode 100644
index 00000000000..a3a960abcd9
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/blood_worm_mob.dm
@@ -0,0 +1,403 @@
+/mob/living/basic/blood_worm
+ icon = 'icons/mob/nonhuman-player/blood_worm_32x32.dmi'
+
+ faction = list(FACTION_BLOOD_WORM)
+
+ initial_language_holder = /datum/language_holder/blood_worm
+
+ // FIXME: This should use MOB_BUG, but it makes hunter boxing instakill juveniles.
+ // I.e. blood worms should be susceptible to pest killer, but not hunter boxing.
+ mob_biotypes = MOB_ORGANIC
+ basic_mob_flags = FLAMMABLE_MOB
+ status_flags = CANPUSH // No CANSTUN, blood worms are immune to stuns by design.
+
+ damage_coeff = list(BRUTE = 1, BURN = 1.5, TOX = 0, STAMINA = 0, OXY = 0)
+
+ pressure_resistance = 200
+
+ combat_mode = TRUE
+
+ melee_attack_cooldown = CLICK_CD_MELEE
+
+ attack_sound = 'sound/items/weapons/bite.ogg'
+ attack_vis_effect = ATTACK_EFFECT_BITE
+ attack_verb_continuous = "bites"
+ attack_verb_simple = "bite"
+
+ minimum_survivable_temperature = 0
+ maximum_survivable_temperature = T0C + 100
+ unsuitable_cold_damage = 0
+
+ habitable_atmos = null
+
+ // A vivid red.
+ lighting_cutoff_red = 40
+ lighting_cutoff_green = 20
+ lighting_cutoff_blue = 20
+
+ /// Identification number, i.e. "hatchling blood worm (id)"
+ /// Used for carrying the same number through growth stages.
+ var/id_number = null
+
+ /// Effect name for stuff like "invade-[effect_name]".
+ /// Should correspond to the growth stage, like "adult".
+ var/effect_name = ""
+
+ /// How long the leave host animation lasts for this type, in deciseconds.
+ var/leave_host_duration = 0
+
+ /// How much regular (human, blood pack, etc.) blood the worm has consumed.
+ var/consumed_normal_blood = 0
+ /// How much synthetic (monkey, duplicated, etc.) blood the worm has consumed.
+ var/consumed_synth_blood = 0
+ /// The maximum amount of synthetic blood counted for growth.
+ var/maximum_synth_blood = 1000
+ /// How efficient ingesting synthetic blood is compared to normal blood. (ingested amount is multiplied by this)
+ var/synth_blood_efficiency = 0.7
+
+ /// The current host of the blood worm, if any.
+ /// You can use this to check if the blood worm has a host.
+ var/mob/living/carbon/human/host
+ /// The backseat mob for the mind of the current host, if any.
+ /// This mob is always dead as it's just a mind holder.
+ var/mob/living/blood_worm_host/backseat
+
+ /// The blood display on the left side of the screen, which is shown to the blood worm while in a host, if any.
+ var/atom/movable/screen/blood_level/blood_display
+
+ // Innate and shared actions
+
+ /// Typed, please initialize with a proper action subtype. (empty = no action)
+ var/datum/action/cooldown/mob_cooldown/blood_worm/spit/spit_action
+ /// Typed, please initialize with a proper action subtype. (empty = no action)
+ var/datum/action/cooldown/mob_cooldown/blood_worm/leech/leech_action
+ /// Not typed, please leave empty.
+ var/datum/action/cooldown/mob_cooldown/blood_worm/invade/invade_action
+ /// Typed, please initialize with a proper action subtype. (empty = no action)
+ var/datum/action/cooldown/mob_cooldown/blood_worm/cocoon/cocoon_action
+
+ // Host actions
+
+ /// Typed, please initialize with a proper action subtype. (empty = no action)
+ var/datum/action/cooldown/mob_cooldown/blood_worm/inject/transfuse_action
+ /// Not typed, please leave empty.
+ var/datum/action/cooldown/mob_cooldown/blood_worm/eject/eject_action
+ /// Not typed, please leave empty.
+ var/datum/action/cooldown/mob_cooldown/blood_worm/revive/revive_action
+
+ /// List of actions outside of a host.
+ var/list/innate_actions = list()
+ /// List of actions inside of a host.
+ var/list/host_actions = list()
+
+ /// Whether the blood worm has a host AND is currently in control of that host.
+ var/is_possessing_host = FALSE
+
+ /// How quickly the blood worm regenerates, in health per second.
+ var/regen_rate = 0
+
+ COOLDOWN_DECLARE(host_heat_alert_cooldown)
+ COOLDOWN_DECLARE(host_bleed_alert_cooldown)
+
+/mob/living/basic/blood_worm/Initialize(mapload)
+ . = ..()
+
+ // Innate and shared actions
+
+ if (ispath(spit_action, /datum/action/cooldown/mob_cooldown/blood_worm/spit))
+ spit_action = new spit_action(src)
+ innate_actions += spit_action
+ host_actions += spit_action
+
+ if (ispath(leech_action, /datum/action/cooldown/mob_cooldown/blood_worm/leech))
+ leech_action = new leech_action(src)
+ innate_actions += leech_action
+
+ invade_action = new(src)
+ innate_actions += invade_action
+
+ if (ispath(cocoon_action, /datum/action/cooldown/mob_cooldown/blood_worm/cocoon))
+ cocoon_action = new cocoon_action(src)
+ innate_actions += cocoon_action
+
+ // Host actions
+
+ if (ispath(transfuse_action, /datum/action/cooldown/mob_cooldown/blood_worm/inject))
+ transfuse_action = new transfuse_action(src)
+ host_actions += transfuse_action
+
+ eject_action = new(src)
+ host_actions += eject_action
+
+ revive_action = new(src)
+ host_actions += revive_action
+
+ grant_actions(src, innate_actions)
+
+ ADD_TRAIT(src, TRAIT_BLOOD_HUD, INNATE_TRAIT)
+
+ // Move speed delays at min health
+ // Hatchling goes from 1.5 up to 2 deciseconds
+ // Juvenile goes from 1.8 up to 2.3 deciseconds
+ // Adult goes from 2 up to 2.5 deciseconds
+ // For reference, a cyborg has a move speed delay of 1.5 deciseconds
+ AddComponent(/datum/component/health_scaling_effects, min_health_slowdown = 0.5)
+
+ AddElement(/datum/element/anti_self_harm)
+
+ id_number = rand(1, 999)
+ update_name()
+
+/mob/living/basic/blood_worm/Destroy()
+ unregister_host()
+
+ spit_action = null
+ leech_action = null
+ invade_action = null
+ cocoon_action = null
+
+ transfuse_action = null
+ eject_action = null
+ revive_action = null
+
+ innate_actions = null
+ host_actions = null
+
+ return ..()
+
+/mob/living/basic/blood_worm/Login()
+ . = ..()
+ if (!. || !client)
+ return FALSE
+ if (host)
+ ADD_TRAIT(host, TRAIT_MIND_TEMPORARILY_GONE, BLOOD_WORM_HOST_TRAIT)
+
+/mob/living/basic/blood_worm/Logout()
+ . = ..()
+ if (host)
+ REMOVE_TRAIT(host, TRAIT_MIND_TEMPORARILY_GONE, BLOOD_WORM_HOST_TRAIT)
+
+/mob/living/basic/blood_worm/process(seconds_per_tick, times_fired)
+ if (!host)
+ return
+
+ sync_health()
+
+/mob/living/basic/blood_worm/Life(seconds_per_tick, times_fired)
+ . = ..()
+
+ if (!host)
+ // Moved to host life while in a host.
+ adjust_worm_health(regen_rate * seconds_per_tick)
+ else
+ bodytemperature = T20C
+
+/mob/living/basic/blood_worm/handle_environment(datum/gas_mixture/environment, seconds_per_tick, times_fired)
+ if (host)
+ bodytemperature = T20C
+ else
+ return ..()
+
+/mob/living/basic/blood_worm/update_name(updates)
+ . = ..()
+ name = "[initial(name)] ([id_number])"
+ real_name = name
+
+/mob/living/basic/blood_worm/adjust_health(amount, updating_health, forced)
+ return host ? 0 : ..() // Prevents damage from adjust_x_loss while in a host, because that damage would be nullified by the next [proc/sync_health] call. Adjust host blood volume instead.
+
+/mob/living/basic/blood_worm/set_stat(new_stat)
+ . = ..()
+
+ if (host && stat != CONSCIOUS)
+ leave_host()
+
+/mob/living/basic/blood_worm/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change)
+ . = ..()
+
+ if (host && loc != host)
+ unregister_host()
+
+/// Gets the current health of the worm, regardless of if its in a host or not.
+/mob/living/basic/blood_worm/proc/get_worm_health()
+ return host ? min(host.get_blood_volume() * BLOOD_WORM_BLOOD_TO_HEALTH, maxHealth) : health
+
+/// Adjusts the current health of the worm, regardless of if its in a host or not.
+/mob/living/basic/blood_worm/proc/adjust_worm_health(amount)
+ return host ? host.adjust_blood_volume(amount * BLOOD_WORM_HEALTH_TO_BLOOD) * BLOOD_WORM_BLOOD_TO_HEALTH : adjust_brute_loss(-amount)
+
+/mob/living/basic/blood_worm/proc/grant_actions(mob/target, list/actions)
+ for (var/datum/action/action as anything in actions)
+ action.Grant(target)
+
+/mob/living/basic/blood_worm/proc/remove_actions(mob/target, list/actions)
+ for (var/datum/action/action as anything in actions)
+ action.Remove(target)
+
+/mob/living/basic/blood_worm/proc/sync_health(already_ejecting = FALSE)
+ if (!host)
+ return
+
+ var/host_max_blood = maxHealth * BLOOD_WORM_HEALTH_TO_BLOOD
+
+ // Cap host blood to maximum
+ if (host.get_blood_volume() > host_max_blood)
+ host.set_blood_volume(host_max_blood)
+
+ var/cached_blood_volume = host.get_blood_volume()
+
+ // Sync mob health to host blood
+ set_brute_loss(maxHealth * (1 - cached_blood_volume / host_max_blood))
+
+ // Checks if we still have a host since setBruteLoss() can kill us, causing us to leave our host.
+ if (!already_ejecting && cached_blood_volume <= get_eject_volume_threshold())
+ // Sent before leave_host() for the correct message order in chat
+ to_chat_self(span_userdanger("You run out of blood to control your host with!"))
+
+ leave_host()
+
+ // Has to be sent after the forceMove() in leave_host()
+ balloon_alert_self("out_of_blood!")
+
+/// Gets BLOOD_WORM_EJECT_THRESHOLD as an actionable blood volume threshold.
+/mob/living/basic/blood_worm/proc/get_eject_volume_threshold()
+ return maxHealth * BLOOD_WORM_HEALTH_TO_BLOOD * BLOOD_WORM_EJECT_THRESHOLD
+
+/obj/effect/temp_visual/blood_worm_invade_host
+ icon = 'icons/mob/nonhuman-player/blood_worm_32x32.dmi'
+ icon_state = "invade-hatchling" // Not actually used for anything, it's just a default because otherwise the unit tests scream about it.
+ duration = 2 SECONDS
+
+/obj/effect/temp_visual/blood_worm_invade_host/Initialize(mapload, effect_name)
+ . = ..()
+ icon_state = "invade-[effect_name]"
+
+/mob/living/basic/blood_worm/hatchling
+ name = "hatchling blood worm"
+ desc = "A freshly hatched blood worm. It looks hungry and weak, requiring blood to grow further."
+
+ icon_state = "hatchling"
+ icon_living = "hatchling"
+ icon_dead = "hatchling-dead"
+
+ mob_size = MOB_SIZE_TINY
+ pass_flags = PASSTABLE | PASSMOB // The benefits of being a tiny little bastard.
+
+ guaranteed_butcher_results = list(/obj/item/food/meat/slab/blood_worm = 1)
+
+ maxHealth = 80 // In practice, escaping into a vent from someone who could 3 hit you with a basic bitch welder was really hard. This used to be 50, and was buffed to 80, but speed was slowed a bit.
+ health = 80
+
+ unsuitable_heat_damage = 1
+
+ obj_damage = 15 // 10 -> 15, in testing 10 proved to be way too slow at breaking morgue trays and such. Make sure that this doesn't go above airlock damage deflection.
+ melee_damage_lower = 12
+ melee_damage_upper = 14
+ armour_penetration = 10
+
+ speed = 0
+
+ effect_name = "hatchling"
+ leave_host_duration = 0.6 SECONDS
+
+ leech_action = /datum/action/cooldown/mob_cooldown/blood_worm/leech/hatchling
+ cocoon_action = /datum/action/cooldown/mob_cooldown/blood_worm/cocoon/hatchling
+
+ transfuse_action = /datum/action/cooldown/mob_cooldown/blood_worm/inject/hatchling
+
+ regen_rate = 0.3 // 266 seconds to recover from 0 to 80, or almost 4 and a half minutes.
+
+/mob/living/basic/blood_worm/hatchling/Initialize(mapload)
+ . = ..()
+
+ ADD_TRAIT(src, TRAIT_VENTCRAWLER_ALWAYS, INNATE_TRAIT)
+
+ AddComponent(/datum/component/slide_under_doors, slide_in_delay = 3 SECONDS)
+
+/mob/living/basic/blood_worm/juvenile
+ name = "juvenile blood worm"
+ desc = "A mid-sized blood worm. It looks bloodthirsty and has numerous long and extremely sharp teeth."
+
+ icon_state = "juvenile"
+ icon_living = "juvenile"
+ icon_dead = "juvenile-dead"
+
+ mob_size = MOB_SIZE_SMALL
+
+ guaranteed_butcher_results = list(/obj/item/food/meat/slab/blood_worm = 2)
+
+ maxHealth = 120 // Note that the juveniles are bigger and slower than hatchlings, making them far easier to hit by comparison.
+ health = 120
+
+ unsuitable_heat_damage = 1.5
+
+ obj_damage = 35 // Able to break most obstacles, such as airlocks. This is mandatory since they can't ventcrawl anymore.
+ melee_damage_lower = 18 // Juveniles can't run away nearly as easily, so they are expected to do direct combat against normal crew. (but lose hard to well-equipped sec)
+ melee_damage_upper = 22
+ armour_penetration = 30
+
+ wound_bonus = 0 // Juveniles can afford to heal wounds on their hosts, unlike hatchlings. Note that this can't cause critical wounds. (at least it didn't in testing)
+ sharpness = SHARP_POINTY
+
+ speed = 0.3
+
+ effect_name = "juvenile"
+ leave_host_duration = 1 SECONDS
+
+ spit_action = /datum/action/cooldown/mob_cooldown/blood_worm/spit/juvenile
+ leech_action = /datum/action/cooldown/mob_cooldown/blood_worm/leech/juvenile
+ cocoon_action = /datum/action/cooldown/mob_cooldown/blood_worm/cocoon/juvenile
+
+ transfuse_action = /datum/action/cooldown/mob_cooldown/blood_worm/inject/juvenile
+
+ regen_rate = 0.4 // 300 seconds to recover from 0 to 120, or exactly 5 minutes.
+
+/mob/living/basic/blood_worm/juvenile/Initialize(mapload)
+ . = ..()
+
+ AddComponent(/datum/component/slide_under_doors, slide_in_delay = 5 SECONDS)
+
+/mob/living/basic/blood_worm/adult
+ name = "adult blood worm"
+ desc = "A monstrosity of a blood worm. It'd probably be better to put your head in an industrial shredder rather than its maw."
+
+ icon = 'icons/mob/nonhuman-player/blood_worm_32x48.dmi'
+
+ icon_state = "adult"
+ icon_living = "adult"
+ icon_dead = "adult-dead"
+ health_doll_icon = "adult_blood_worm"
+
+ // We undershoot a tiny bit so that Proto-Kinetic Crushers don't work on adult blood worms.
+ mob_size = MOB_SIZE_HUMAN
+
+ guaranteed_butcher_results = list(/obj/item/food/meat/slab/blood_worm = 3)
+
+ maxHealth = 180 // Used to be 150, turns out their lack of armor and weakness to burn made them too squishy. People kited them using lasguns, leaving them with no way to fight back at all.
+ health = 180
+
+ unsuitable_heat_damage = 2
+
+ obj_damage = 50 // You are not getting away.
+ melee_damage_lower = 25
+ melee_damage_upper = 30 // Turns out adults regularly end up encountering advanced weapons like cap's sabre, lasguns + armor, eswords, etc. They need a strong melee.
+ armour_penetration = 50 // Adults will 100% encounter sec, they are shit out of luck without proper armor pen.
+
+ wound_bonus = 0 // Able to cause critical wounds.
+ sharpness = SHARP_POINTY
+
+ attack_verb_simple = "gore"
+ attack_verb_continuous = "gores"
+
+ speed = 0.5
+
+ effect_name = "adult"
+ leave_host_duration = 1.4 SECONDS
+
+ spit_action = /datum/action/cooldown/mob_cooldown/blood_worm/spit/adult
+ leech_action = /datum/action/cooldown/mob_cooldown/blood_worm/leech/adult
+ cocoon_action = /datum/action/cooldown/mob_cooldown/blood_worm/cocoon/adult
+
+ transfuse_action = /datum/action/cooldown/mob_cooldown/blood_worm/inject/adult
+
+ regen_rate = 0.5 // 360 seconds to recover from 0 to 180, or exactly 6 minutes.
diff --git a/code/modules/antagonists/blood_worm/blood_worm_objectives.dm b/code/modules/antagonists/blood_worm/blood_worm_objectives.dm
new file mode 100644
index 00000000000..de74b29b754
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/blood_worm_objectives.dm
@@ -0,0 +1,81 @@
+/datum/objective/blood_worm
+ abstract_type = /datum/objective/blood_worm
+
+/datum/objective/blood_worm/proc/get_blood_worm_team()
+ RETURN_TYPE(/datum/team/blood_worm)
+ return team
+
+/datum/objective/blood_worm/kill
+ name = "KILL"
+ explanation_text = "We must prevent all members of station command from escaping alive on the emergency shuttle."
+
+/datum/objective/blood_worm/kill/check_completion()
+ for (var/mob/player_mob as anything in GLOB.player_list)
+ if (!(player_mob.mind?.assigned_role.departments_bitflags & DEPARTMENT_BITFLAG_COMMAND))
+ continue
+ if (!considered_alive(player_mob.mind))
+ continue
+ if (considered_exiled(player_mob.mind))
+ continue
+ // Counts whiteship as escaped, but not pods.
+ if (player_mob.onCentCom() || player_mob.onSyndieBase())
+ return FALSE
+
+ return TRUE
+
+/datum/objective/blood_worm/consume
+ name = "CONSUME"
+
+ var/blood_required = 0
+
+/datum/objective/blood_worm/consume/New(text)
+ blood_required = rand(20, 30) * 100
+ update_explanation_text()
+
+/datum/objective/blood_worm/consume/update_explanation_text()
+ explanation_text = "We must consume a total of at least [blood_required] units of real blood to sate our appetite."
+
+/datum/objective/blood_worm/consume/check_completion()
+ return get_blood_worm_team().blood_consumed_total >= blood_required
+
+/datum/objective/blood_worm/multiply
+ name = "MULTIPLY"
+
+ var/times_required = 0
+
+/datum/objective/blood_worm/multiply/New(text)
+ times_required = rand(2, 3)
+ update_explanation_text()
+
+/datum/objective/blood_worm/multiply/update_explanation_text()
+ explanation_text = "At least [times_required] of us must reproduce to pave the way for our overwhelming numbers."
+
+/datum/objective/blood_worm/multiply/check_completion()
+ return get_blood_worm_team().times_reproduced_total >= times_required
+
+/datum/objective/blood_worm/conquer
+ name = "CONQUER"
+
+ var/worms_required = 0
+
+/datum/objective/blood_worm/conquer/New(text)
+ worms_required = rand(3, 4)
+ update_explanation_text()
+
+/datum/objective/blood_worm/conquer/update_explanation_text()
+ explanation_text = "At least [worms_required] of us must escape alive on the emergency shuttle to conquer what is on the other side."
+
+/datum/objective/blood_worm/conquer/check_completion()
+ var/conquerors = 0
+
+ for (var/datum/mind/member as anything in team.members)
+ if (QDELETED(member.current))
+ continue
+ if (!isbloodworm(member.current) && !HAS_TRAIT(member.current, TRAIT_BLOOD_WORM_HOST))
+ continue
+ if (member.current.stat == DEAD) // This assumes that the mind of a blood worm that is inhabiting a dead host is in their blood worm mob.
+ continue
+ if (member.current.onCentCom() || member.current.onSyndieBase())
+ conquerors++
+
+ return conquerors >= worms_required
diff --git a/code/modules/antagonists/blood_worm/blood_worm_team.dm b/code/modules/antagonists/blood_worm/blood_worm_team.dm
new file mode 100644
index 00000000000..5d186eb1280
--- /dev/null
+++ b/code/modules/antagonists/blood_worm/blood_worm_team.dm
@@ -0,0 +1,86 @@
+/datum/team/blood_worm
+ name = "\improper Blood Worms"
+ member_name = "blood worm"
+
+ var/blood_consumed_total = 0
+ var/times_reproduced_total = 0
+
+ var/static/list/objective_types = list(
+ /datum/objective/blood_worm/kill,
+ /datum/objective/blood_worm/consume,
+ /datum/objective/blood_worm/multiply,
+ /datum/objective/blood_worm/conquer,
+ )
+
+/datum/team/blood_worm/New(starting_members)
+ . = ..()
+
+ for (var/objective_type in objective_types)
+ objectives += new objective_type()
+
+ for (var/datum/objective/objective as anything in objectives)
+ objective.team = src
+
+/datum/team/blood_worm/add_member(datum/mind/member)
+ . = ..()
+ register_member_mob(member.current)
+ RegisterSignal(member, COMSIG_MIND_TRANSFERRED, PROC_REF(on_mind_transferred))
+
+/datum/team/blood_worm/remove_member(datum/mind/member)
+ UnregisterSignal(member, COMSIG_MIND_TRANSFERRED)
+ unregister_member_mob(member.current)
+ return ..()
+
+/datum/team/blood_worm/proc/on_mind_transferred(datum/mind/member, mob/previous_body)
+ SIGNAL_HANDLER
+ unregister_member_mob(previous_body)
+ register_member_mob(member.current)
+
+/datum/team/blood_worm/proc/register_member_mob(mob/member_mob)
+ if (!member_mob)
+ return
+
+ RegisterSignal(member_mob, COMSIG_BLOOD_WORM_CONSUMED_BLOOD, PROC_REF(on_blood_worm_consumed_blood))
+ RegisterSignal(member_mob, COMSIG_BLOOD_WORM_REPRODUCED, PROC_REF(on_blood_worm_reproduced))
+
+/datum/team/blood_worm/proc/unregister_member_mob(mob/member_mob)
+ if (!member_mob)
+ return
+
+ UnregisterSignal(member_mob, list(COMSIG_BLOOD_WORM_CONSUMED_BLOOD, COMSIG_BLOOD_WORM_REPRODUCED))
+
+/datum/team/blood_worm/proc/on_blood_worm_consumed_blood(mob/living/basic/blood_worm/worm, normal_blood_amount, synth_blood_amount, total_blood_amount)
+ SIGNAL_HANDLER
+ blood_consumed_total += normal_blood_amount
+
+/datum/team/blood_worm/proc/on_blood_worm_reproduced(mob/living/basic/blood_worm/worm)
+ SIGNAL_HANDLER
+ times_reproduced_total++
+
+/datum/team/blood_worm/roundend_report()
+ var/list/report = list()
+
+ report += span_header("\The [name]:")
+ report += printplayerlist(members)
+
+ if (length(objectives))
+ report += span_header("Their collective goals:")
+ report += print_objective_list()
+
+ report += ""
+
+ report += did_we_win() ? span_greentext("The [name] were successful!") : span_redtext("The [name] have failed!")
+
+ return "