diff --git a/code/__DEFINES/alerts.dm b/code/__DEFINES/alerts.dm
index 662711610ee..ea12830d858 100644
--- a/code/__DEFINES/alerts.dm
+++ b/code/__DEFINES/alerts.dm
@@ -14,6 +14,8 @@
#define ALERT_TOO_MUCH_NITRO "too_much_nitro"
#define ALERT_NOT_ENOUGH_NITRO "not_enough_nitro"
+#define ALERT_BRONCHODILATION "bronchodilation"
+
#define ALERT_NOT_ENOUGH_WATER "not_enough_water"
/** Mob related */
diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_carbon.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_carbon.dm
index fe2071df0b2..67d69dc5b05 100644
--- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_carbon.dm
+++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_carbon.dm
@@ -179,3 +179,6 @@
/// From /mob/living/carbon/proc/set_blood_type : (mob/living/carbon/user, datum/blood_type, update_cached_blood_dna_info)
#define COMSIG_CARBON_CHANGED_BLOOD_TYPE "carbon_set_blood_type"
+
+//from base of [/obj/effect/particle_effect/fluid/smoke/proc/smoke_mob]: (seconds_per_tick)
+#define COMSIG_CARBON_EXPOSED_TO_SMOKE "carbon_exposed_to_smoke"
diff --git a/code/__DEFINES/diseases.dm b/code/__DEFINES/diseases.dm
index fe05a30e0fd..379f9a37d29 100644
--- a/code/__DEFINES/diseases.dm
+++ b/code/__DEFINES/diseases.dm
@@ -16,6 +16,8 @@ DEFINE_BITFIELD(visibility_flags, list(
#define CAN_CARRY (1<<1)
#define CAN_RESIST (1<<2)
#define CHRONIC (1<<3)
+/// Instead of instantly curing the disease, cures will simply reduce the stage
+#define INCREMENTAL_CURE (1<<4)
//Spread Flags
#define DISEASE_SPREAD_SPECIAL (1<<0)
diff --git a/code/__DEFINES/reagents.dm b/code/__DEFINES/reagents.dm
index d2f5528bc49..58db74fa044 100644
--- a/code/__DEFINES/reagents.dm
+++ b/code/__DEFINES/reagents.dm
@@ -45,7 +45,7 @@
#define INJECT (1<<4)
/// Exclusive to just plumbing. if set we use the round robin technique else we use proportional
#define LINEAR (1<<5)
-/// Used by smoke or inhaling from a source. Smoke and cigarettes.
+/// Used by smoke or inhaling from a source. Smoke, cigarettes, and inhalers.
#define INHALE (1<<6)
///Smoke machines are both touch and inhaling
diff --git a/code/_onclick/hud/alert.dm b/code/_onclick/hud/alert.dm
index ec7d26d4da0..c10bc5922e2 100644
--- a/code/_onclick/hud/alert.dm
+++ b/code/_onclick/hud/alert.dm
@@ -199,6 +199,16 @@
//End gas alerts
+/atom/movable/screen/alert/bronchodilated
+ name = "Bronchodilated"
+ desc = "You feel like your lungs are larger than usual! You're taking deeper breaths!"
+ icon_state = "bronchodilated"
+
+/atom/movable/screen/alert/bronchoconstricted
+ name = "Bronchocontracted"
+ desc = "You feel like your lungs are smaller than usual! You might need a higher pressure environment/internals to breathe!"
+ icon_state = "bronchoconstricted"
+
/atom/movable/screen/alert/gross
name = "Grossed out."
desc = "That was kind of gross..."
diff --git a/code/datums/diseases/_disease.dm b/code/datums/diseases/_disease.dm
index e19c19151e3..d3c8c0f7dc0 100644
--- a/code/datums/diseases/_disease.dm
+++ b/code/datums/diseases/_disease.dm
@@ -117,11 +117,13 @@
update_stage(1)
to_chat(affected_mob, span_notice("Your chronic illness is alleviated a little, though it can't be cured!"))
return
- if(SPT_PROB(cure_mod, seconds_per_tick))
- update_stage(max(stage - 1, 1))
if(disease_flags & CURABLE && SPT_PROB(cure_mod, seconds_per_tick))
- cure()
- return FALSE
+ if(disease_flags & INCREMENTAL_CURE)
+ if (!update_stage(stage - 1))
+ return FALSE
+ else
+ cure()
+ return FALSE
if(stage == max_stages && stage_peaked != TRUE) //mostly a sanity check in case we manually set a virus to max stages
stage_peaked = TRUE
@@ -233,6 +235,10 @@
stage = new_stage
if(new_stage == max_stages && !(stage_peaked)) //once a virus has hit its peak, set it to have done so
stage_peaked = TRUE
+ if (stage <= 0)
+ cure()
+ return FALSE
+ return TRUE
/datum/disease/proc/has_cure()
if(!(disease_flags & (CURABLE | CHRONIC)))
diff --git a/code/datums/diseases/asthma_attack.dm b/code/datums/diseases/asthma_attack.dm
new file mode 100644
index 00000000000..7adefd2f746
--- /dev/null
+++ b/code/datums/diseases/asthma_attack.dm
@@ -0,0 +1,262 @@
+/datum/disease/asthma_attack
+ form = "Bronchitis"
+ name = "Asthma attack"
+ desc = "Subject is undergoing a autoimmune response which threatens to close the esophagus and halt all respiration, leading to death. \
+ Minor asthma attacks may disappear on their own, but all are dangerous."
+ cure_text = "Albuterol/Surgical intervention"
+ cures = list(/datum/reagent/medicine/albuterol)
+ agent = "Inflammatory"
+ viable_mobtypes = list(/mob/living/carbon/human)
+ disease_flags = CURABLE
+ spread_flags = DISEASE_SPREAD_NON_CONTAGIOUS
+ spread_text = "Inflammatory"
+ visibility_flags = HIDDEN_PANDEMIC
+ bypasses_immunity = TRUE
+ disease_flags = CURABLE|INCREMENTAL_CURE
+ required_organ = ORGAN_SLOT_LUNGS
+ infectable_biotypes = MOB_ROBOTIC|MOB_ORGANIC|MOB_MINERAL|MOB_UNDEAD
+
+ /// The world.time after which we will begin remission.
+ var/time_to_start_remission
+
+ /// The max time, after initial infection, it will take for us to begin remission
+ var/max_time_til_remission
+ /// The min time, after initial infection, it will take for us to begin remission
+ var/min_time_til_remission
+
+ /// Are we in remission, where we stop progressing and instead slowly degrade in intensity until we remove ourselves?
+ var/in_remission = FALSE
+
+ /// The current progress to stage demotion. Resets to 0 and reduces our stage by 1 when it exceeds [progress_needed_to_demote]. Only increases when in remission.
+ var/progress_to_stage_demotion = 0
+ /// The amount of demotion progress we receive per second while in remission.
+ var/progress_to_demotion_per_second = 1
+ /// Once [progress_to_stage_demotion] exceeds or meets this, we reduce our stage.
+ var/progress_needed_to_demote = 10
+
+ /// Do we alert ghosts when we are applied?
+ var/alert_ghosts = FALSE
+
+ /// A assoc list of (severity -> string), where string will be suffixed to our name in (suffix) format.
+ var/static/list/severity_to_suffix = list(
+ DISEASE_SEVERITY_MEDIUM = "Minor",
+ DISEASE_SEVERITY_HARMFUL = "Moderate",
+ DISEASE_SEVERITY_DANGEROUS = "Severe",
+ DISEASE_SEVERITY_BIOHAZARD = "EXTREME",
+ )
+ /// A assoc list of (stringified number -> number), where the key is the stage and the number is how much inflammation we will cause the asthmatic per second.
+ var/list/stage_to_inflammation_per_second
+
+/datum/disease/asthma_attack/New()
+ . = ..()
+
+ suffix_name()
+
+ time_to_start_remission = world.time + rand(min_time_til_remission, max_time_til_remission)
+
+/datum/disease/asthma_attack/try_infect(mob/living/infectee, make_copy)
+ if (!get_asthma_quirk())
+ return FALSE
+ if (HAS_TRAIT(infectee, TRAIT_NOBREATH))
+ return FALSE
+
+ return ..()
+
+/// Adds our suffix via [severity_to_suffix] in the format of (suffix) to our name.
+/datum/disease/asthma_attack/proc/suffix_name()
+ name += " ([severity_to_suffix[severity]])"
+
+/// Returns the asthma quirk of our victim. As we can only be applied to asthmatics, this should never return null.
+/datum/disease/asthma_attack/proc/get_asthma_quirk(mob/living/target = affected_mob)
+ RETURN_TYPE(/datum/quirk/item_quirk/asthma)
+
+ return (locate(/datum/quirk/item_quirk/asthma) in target.quirks)
+
+/datum/disease/asthma_attack/stage_act(seconds_per_tick, times_fired)
+ . = ..()
+ if (!.)
+ return
+
+ if (HAS_TRAIT(affected_mob, TRAIT_NOBREATH))
+ cure()
+ return FALSE
+
+ var/datum/quirk/item_quirk/asthma/asthma_quirk = get_asthma_quirk()
+ var/inflammation = stage_to_inflammation_per_second["[stage]"]
+ if (inflammation)
+ asthma_quirk.adjust_inflammation(inflammation * seconds_per_tick)
+
+ if (!(world.time >= time_to_start_remission))
+ return
+
+ if (!in_remission)
+ in_remission = TRUE
+ stage_prob = 0
+ name += " (Remission)"
+ desc += " The attack has entered remission. It will slowly decrease in intensity before vanishing."
+ progress_to_stage_demotion += (progress_to_demotion_per_second * seconds_per_tick)
+ if (progress_to_stage_demotion >= progress_needed_to_demote)
+ progress_to_stage_demotion = 0
+ update_stage(stage - 1)
+
+// TYPES OF ASTHMA ATTACK
+
+/datum/disease/asthma_attack/minor
+ severity = DISEASE_SEVERITY_MEDIUM
+ stage_prob = 4
+
+ max_time_til_remission = 120 SECONDS
+ min_time_til_remission = 80 SECONDS
+ max_stages = 3
+
+ cure_chance = 20
+
+ stage_to_inflammation_per_second = list(
+ "2" = 0.3,
+ "3" = 0.6,
+ )
+
+/datum/disease/asthma_attack/minor/stage_act(seconds_per_tick, times_fired)
+ . = ..()
+ if (!.)
+ return FALSE
+
+ if (SPT_PROB(5, seconds_per_tick))
+ to_chat(affected_mob, span_warning(pick("Mucous runs down the back of your throat.", "You swallow excess mucus.")))
+
+/datum/disease/asthma_attack/moderate
+ severity = DISEASE_SEVERITY_HARMFUL
+ stage_prob = 5
+
+ max_time_til_remission = 120 SECONDS
+ min_time_til_remission = 80 SECONDS
+ max_stages = 4
+
+ cure_chance = 20
+
+ stage_to_inflammation_per_second = list(
+ "2" = 1,
+ "3" = 2,
+ "4" = 4,
+ )
+
+/datum/disease/asthma_attack/moderate/stage_act(seconds_per_tick, times_fired)
+ . = ..()
+ if (!.)
+ return FALSE
+
+ if (SPT_PROB(15, seconds_per_tick))
+ to_chat(affected_mob, span_warning(pick("Mucous runs down the back of your throat.", "You swallow excess mucus.")))
+
+ if (stage < 4 || !SPT_PROB(10, seconds_per_tick))
+ return
+ to_chat(affected_mob, span_warning("You briefly choke on the mucus piling in your throat!"))
+ affected_mob.losebreath++
+
+
+/datum/disease/asthma_attack/severe
+ severity = DISEASE_SEVERITY_DANGEROUS
+ stage_prob = 6
+
+ max_time_til_remission = 80 SECONDS
+ min_time_til_remission = 60 SECONDS
+ max_stages = 5
+
+ cure_chance = 20
+
+ stage_to_inflammation_per_second = list(
+ "2" = 1,
+ "3" = 3,
+ "4" = 6,
+ "5" = 8,
+ )
+
+ visibility_flags = HIDDEN_SCANNER
+ alert_ghosts = TRUE
+
+/datum/disease/asthma_attack/severe/stage_act(seconds_per_tick, times_fired)
+ . = ..()
+ if (!.)
+ return FALSE
+
+ if (stage > 1)
+ visibility_flags &= ~HIDDEN_SCANNER // revealed
+
+ if (SPT_PROB(15, seconds_per_tick))
+ to_chat(affected_mob, span_warning(pick("Mucous runs down the back of your throat.", "You swallow excess mucus.")))
+ else if (SPT_PROB(20, seconds_per_tick))
+ affected_mob.emote("cough")
+
+ if (stage < 4 || !SPT_PROB(15, seconds_per_tick))
+ return
+ to_chat(affected_mob, span_warning("You briefly choke on the mucus piling in your throat!"))
+ affected_mob.losebreath++
+
+/datum/disease/asthma_attack/critical
+ severity = DISEASE_SEVERITY_BIOHAZARD
+ stage_prob = 85
+
+ max_time_til_remission = 60 SECONDS // this kills you extremely quickly, so its fair
+ min_time_til_remission = 40 SECONDS
+ max_stages = 6
+
+ cure_chance = 30
+
+ stage_to_inflammation_per_second = list(
+ "1" = 5,
+ "2" = 6,
+ "3" = 7,
+ "4" = 10,
+ "5" = 20,
+ "6" = 500, // youre fucked frankly
+ )
+
+ /// Have we warned our user of the fact they are at stage 5? If no, and are at or above stage five, we send a warning and set this to true.
+ var/warned_user = FALSE
+ /// Have we ever reached our max stage? If no, and we are at our max stage, we send a ominous message warning them of their imminent demise.
+ var/max_stage_reached = FALSE
+
+/datum/disease/asthma_attack/critical/stage_act(seconds_per_tick, times_fired)
+ . = ..()
+ if (!.)
+ return FALSE
+
+ if (stage < 5)
+ if (SPT_PROB(75, seconds_per_tick))
+ to_chat(affected_mob, span_warning(pick("Mucous runs down the back of your throat.", "You swallow excess mucus.")))
+
+ var/wheeze_chance
+ if (!warned_user && stage >= 5)
+ to_chat(affected_mob, span_userdanger("You feel like your lungs are filling with fluid! It's getting incredibly hard to breathe!"))
+ warned_user = TRUE
+
+ switch (stage)
+ if (1)
+ wheeze_chance = 0
+ if (2)
+ wheeze_chance = 20
+ if (3)
+ wheeze_chance = 40
+ if (4)
+ wheeze_chance = 60
+ if (5)
+ wheeze_chance = 80
+ if (!in_remission)
+ stage_prob = 10 // slow it down significantly
+ if (6)
+ if (!max_stage_reached)
+ max_stage_reached = TRUE
+ to_chat(affected_mob, span_userdanger("You feel your windpipe squeeze shut!"))
+ wheeze_chance = 0
+ if (SPT_PROB(10, seconds_per_tick))
+ affected_mob.emote("gag")
+ var/datum/quirk/item_quirk/asthma/asthma_quirk = get_asthma_quirk()
+ asthma_quirk.adjust_inflammation(INFINITY)
+
+ if (SPT_PROB(wheeze_chance, seconds_per_tick))
+ affected_mob.emote("wheeze")
+
+ if (stage < 4 || !SPT_PROB(15, seconds_per_tick))
+ return
+ to_chat(affected_mob, span_warning("You briefly choke on the mucus piling in your throat!"))
+ affected_mob.losebreath++
diff --git a/code/datums/quirks/negative_quirks/allergic.dm b/code/datums/quirks/negative_quirks/allergic.dm
index a2442adab10..25621f7970e 100644
--- a/code/datums/quirks/negative_quirks/allergic.dm
+++ b/code/datums/quirks/negative_quirks/allergic.dm
@@ -22,6 +22,7 @@
/datum/reagent/medicine/diphenhydramine,
/datum/reagent/medicine/sansufentanyl,
/datum/reagent/medicine/salglu_solution,
+ /datum/reagent/medicine/albuterol,
)
var/allergy_string
diff --git a/code/datums/quirks/negative_quirks/asthma.dm b/code/datums/quirks/negative_quirks/asthma.dm
new file mode 100644
index 00000000000..6683869a646
--- /dev/null
+++ b/code/datums/quirks/negative_quirks/asthma.dm
@@ -0,0 +1,249 @@
+/datum/quirk/item_quirk/asthma
+ name = "Asthma"
+ desc = "You suffer from asthma, a inflammatory disorder that causes your airpipe to squeeze shut! Be careful around smoke!"
+ icon = FA_ICON_LUNGS_VIRUS
+ value = -4 // trivialized by NOBREATH but still quite dangerous
+ gain_text = span_danger("You have a harder time breathing.")
+ lose_text = span_notice("You suddenly feel like your lungs just got a lot better at breathing!")
+ medical_record_text = "Patient suffers from asthma."
+ hardcore_value = 2
+ quirk_flags = QUIRK_HUMAN_ONLY
+ mail_goodies = list(/obj/item/reagent_containers/inhaler_canister/albuterol)
+
+ /// At this percentage of inflammation, our lung pressure mult reaches 0. From 0-1.
+ var/hit_max_mult_at_inflammation_percent = 0.9
+
+ /// Current inflammation of the lungs.
+ var/inflammation = 0
+ /// Highest possible inflammation. Interacts with [hit_max_mult_at_inflammation_percent]
+ var/max_inflammation = 500
+
+ /// The amount [inflammation] reduces every second while our owner is off stasis and alive.
+ var/passive_inflammation_reduction = 0.15
+
+ /// The amount of inflammation we will receive when our owner breathes smoke.
+ var/inflammation_on_smoke = 7.5
+
+ /// If our owner is metabolizing histamine, inflammation will increase by this per tick.
+ var/histamine_inflammation = 2
+ /// If our owner is ODing on histamine, inflammation will increase by this per tick.
+ var/histamine_OD_inflammation = 10 // allergic reactions tend to fuck people up
+
+ /// A tracker variable for how much albuterol has been inhaled.
+ var/inhaled_albuterol = 0
+ /// If [inhaled_albuterol] is above 0, we will reduce inflammation by this much per tick.
+ var/albuterol_inflammation_reduction = 3
+ /// When albuterol is inhaled, inflammation will be reduced via (inhaled_albuterol * albuterol_inflammation_reduction * albuterol_immediate_reduction_mult)
+ var/albuterol_immediate_reduction_mult = 4
+
+ /// The current asthma attack trying to kill our owner.
+ var/datum/disease/asthma_attack/current_attack
+ /// Can we cause an asthma attack?
+ COOLDOWN_DECLARE(next_attack_cooldown)
+
+ /// world.time + this is the time the first attack can happen. Used on spawn.
+ var/time_first_attack_can_happen = 10 MINUTES
+
+ /// After an attack ends, this is the minimum time we must wait before we attack again.
+ var/min_time_between_attacks = 15 MINUTES
+ /// After an attack ends, this is the maximum time we must wait before we attack again.
+ var/max_time_between_attacks = 25 MINUTES
+
+ /// Every second, an asthma attack can happen via this probability. 0-1.
+ var/chance_for_attack_to_happen_per_second = 0.05
+
+ /// Assoc list of (/datum/disease/asthma_attack typepath -> number). Used in pickweight for when we pick a random asthma attack to apply.
+ var/static/list/asthma_attack_rarities = list(
+ /datum/disease/asthma_attack/minor = 300,
+ /datum/disease/asthma_attack/moderate = 400,
+ /datum/disease/asthma_attack/severe = 100,
+ /datum/disease/asthma_attack/critical = 1, // this can quickly kill you, so its rarity is justified
+ )
+
+/datum/quirk/item_quirk/asthma/add_unique(client/client_source)
+ . = ..()
+
+ var/obj/item/inhaler/albuterol/asthma/rescue_inhaler = new(get_turf(quirk_holder))
+ give_item_to_holder(rescue_inhaler, list(LOCATION_BACKPACK, LOCATION_HANDS), flavour_text = "You can use this to quickly relieve the symptoms of your asthma.")
+
+ RegisterSignal(quirk_holder, COMSIG_CARBON_EXPOSED_TO_SMOKE, PROC_REF(holder_exposed_to_smoke))
+ RegisterSignal(quirk_holder, COMSIG_CARBON_LOSE_ORGAN, PROC_REF(organ_removed))
+ RegisterSignal(quirk_holder, COMSIG_ATOM_EXPOSE_REAGENTS, PROC_REF(exposed_to_reagents))
+ RegisterSignal(quirk_holder, COMSIG_LIVING_POST_FULLY_HEAL, PROC_REF(on_full_heal))
+ RegisterSignal(quirk_holder, COMSIG_LIVING_LIFE, PROC_REF(on_life))
+
+ COOLDOWN_START(src, next_attack_cooldown, time_first_attack_can_happen)
+
+/datum/quirk/item_quirk/asthma/remove()
+ . = ..()
+
+ current_attack?.cure()
+ UnregisterSignal(quirk_holder, COMSIG_CARBON_EXPOSED_TO_SMOKE, COMSIG_CARBON_LOSE_ORGAN, COMSIG_ATOM_EXPOSE_REAGENTS, COMSIG_LIVING_POST_FULLY_HEAL, COMSIG_LIVING_LIFE)
+
+/datum/quirk/item_quirk/asthma/proc/on_life(mob/living/source, seconds_per_tick, times_fired)
+ SIGNAL_HANDLER
+
+ if (quirk_holder.stat == DEAD)
+ return
+
+ if (HAS_TRAIT(quirk_holder, TRAIT_STASIS) || HAS_TRAIT(quirk_holder, TRAIT_NO_TRANSFORM))
+ return
+
+ var/obj/item/organ/lungs/holder_lungs = quirk_holder.get_organ_slot(ORGAN_SLOT_LUNGS)
+ if (isnull(holder_lungs))
+ return
+
+ adjust_inflammation(-passive_inflammation_reduction * seconds_per_tick)
+
+ var/datum/reagent/toxin/histamine/holder_histamine = quirk_holder.reagents.has_reagent(/datum/reagent/toxin/histamine)
+ if (holder_histamine)
+ if (holder_histamine.overdosed) // uh oh!
+ if (SPT_PROB(15, seconds_per_tick))
+ to_chat(quirk_holder, span_boldwarning("You feel your neck swelling, squeezing on your windpipe more and more!"))
+ adjust_inflammation(histamine_OD_inflammation * seconds_per_tick)
+ else
+ if (SPT_PROB(5, seconds_per_tick))
+ to_chat(quirk_holder, span_warning("You find yourself wheezing a little harder as your neck swells..."))
+ adjust_inflammation(histamine_inflammation * seconds_per_tick)
+
+ var/datum/reagent/medicine/albuterol/albuterol = quirk_holder.reagents.has_reagent(/datum/reagent/medicine/albuterol)
+ if (!albuterol) // sanity - couldve been purged. can be 0 or null which is why we just use a !
+ inhaled_albuterol = 0
+ else
+ inhaled_albuterol = min(albuterol.volume, inhaled_albuterol)
+
+ if (inhaled_albuterol > 0)
+ adjust_inflammation(-(albuterol_inflammation_reduction * seconds_per_tick))
+
+ // asthma attacks dont happen if theres no client, because they can just kill you and some need immediate response
+ else if (quirk_holder.client && isnull(current_attack) && COOLDOWN_FINISHED(src, next_attack_cooldown) && SPT_PROB(chance_for_attack_to_happen_per_second, seconds_per_tick))
+ do_asthma_attack()
+
+/// Causes an asthma attack via infecting our owner with the attack disease. Notifies ghosts.
+/datum/quirk/item_quirk/asthma/proc/do_asthma_attack()
+ var/datum/disease/asthma_attack/typepath = pick_weight(asthma_attack_rarities)
+
+ current_attack = new typepath
+ current_attack.infect(quirk_holder, make_copy = FALSE) // dont leave make_copy on TRUE. worst mistake ive ever made
+ RegisterSignal(current_attack, COMSIG_QDELETING, PROC_REF(attack_deleting))
+
+ if (current_attack.alert_ghosts)
+ notify_ghosts("[quirk_holder] is having an asthma attack: [current_attack.name]!", source = quirk_holder, notify_flags = NOTIFY_CATEGORY_NOFLASH, header = "Asthma attack!")
+
+/// Setter proc for [inflammation]. Adjusts the amount by lung health, adjusts pressure mult, gives feedback messages if silent is FALSE.
+/datum/quirk/item_quirk/asthma/proc/adjust_inflammation(amount, silent = FALSE)
+ var/old_inflammation = inflammation
+
+ var/obj/item/organ/lungs/holder_lungs = quirk_holder.get_organ_slot(ORGAN_SLOT_LUNGS)
+ var/health_mult = get_lung_health_mult(holder_lungs)
+ if (amount > 0) // make it worse
+ amount *= (2 - health_mult)
+ else // reduce the reduction
+ amount *= health_mult
+
+ var/old_pressure_mult = get_pressure_mult()
+ inflammation = (clamp(inflammation + amount, 0, max_inflammation))
+ var/difference = (old_inflammation - inflammation)
+ if (difference != 0)
+ var/new_pressure_mult = get_pressure_mult()
+ var/pressure_difference = new_pressure_mult - old_pressure_mult
+
+ holder_lungs?.adjust_received_pressure_mult(pressure_difference)
+
+ if (!silent)
+ INVOKE_ASYNC(src, PROC_REF(do_inflammation_change_feedback), difference)
+
+/// Setter proc for [inhaled_albuterol]. Adjusts inflammation immediately.
+/datum/quirk/item_quirk/asthma/proc/adjust_albuterol_levels(adjustment)
+ if (adjustment > 0)
+ var/obj/item/organ/lungs/holder_lungs = quirk_holder.get_organ_slot(ORGAN_SLOT_LUNGS)
+
+ if (isnull(holder_lungs) || holder_lungs.received_pressure_mult <= 0) // it didnt go into the lungs get fucked
+ return
+
+ adjust_inflammation(-(albuterol_inflammation_reduction * albuterol_immediate_reduction_mult))
+
+ inhaled_albuterol += adjustment
+
+/// Returns the pressure mult to be applied to our lungs.
+/datum/quirk/item_quirk/asthma/proc/get_pressure_mult()
+ var/virtual_max = (max_inflammation * hit_max_mult_at_inflammation_percent)
+
+ return (1 - (min(inflammation/virtual_max, 1)))
+
+/// Sends feedback to our owner of which direction our asthma is intensifying/recovering.
+/datum/quirk/item_quirk/asthma/proc/do_inflammation_change_feedback(difference)
+ var/change_mult = 1 + (difference / 300) // 300 is arbitrary
+ if (difference > 0) // it decreased
+ if (prob(1 * change_mult))
+ // in my experience with asthma an inhaler causes a bunch of mucous and you tend to cough it up
+ to_chat(quirk_holder, span_notice("The phlem in your throat forces you to cough!"))
+ quirk_holder.emote("cough")
+
+ else if (difference < 0)// it increased
+ if (prob(1 * change_mult))
+ quirk_holder.emote("wheeze")
+ if (prob(5 * change_mult))
+ to_chat(quirk_holder, span_warning("You feel your windpipe tightening..."))
+
+/// Returns the % of health our lungs have, from 1-0. Used in reducing recovery and intensifying inflammation.
+/datum/quirk/item_quirk/asthma/proc/get_lung_health_mult()
+ var/mob/living/carbon/carbon_quirk_holder = quirk_holder
+ var/obj/item/organ/lungs/holder_lungs = carbon_quirk_holder.get_organ_slot(ORGAN_SLOT_LUNGS)
+ if (isnull(holder_lungs))
+ return 1
+ if (holder_lungs.organ_flags & ORGAN_FAILING)
+ return 0
+ return (1 - (holder_lungs.damage / holder_lungs.maxHealth))
+
+/// Signal proc for when we are exposed to smoke. Increases inflammation.
+/datum/quirk/item_quirk/asthma/proc/holder_exposed_to_smoke(datum/signal_source, seconds_per_tick)
+ SIGNAL_HANDLER
+
+ adjust_inflammation(inflammation_on_smoke * seconds_per_tick)
+
+/// Signal proc for when our lungs are removed. Resets all our variables.
+/datum/quirk/item_quirk/asthma/proc/organ_removed(datum/signal_source, obj/item/organ/removed)
+ SIGNAL_HANDLER
+
+ if (istype(removed, /obj/item/organ/lungs))
+ reset_asthma()
+
+/// Signal proc for when our owner receives reagents. If we receive albuterol via inhalation, we adjust inhaled albuterol by that amount. If we are smoking, we increase inflammation.
+/datum/quirk/item_quirk/asthma/proc/exposed_to_reagents(atom/source, list/reagents, datum/reagents/source_reagents, methods, show_message)
+ SIGNAL_HANDLER
+
+ var/final_total = 0
+
+ for (var/datum/reagent/reagent as anything in reagents)
+ var/amount = reagents[reagent]
+ if (istype(reagent, /datum/reagent/medicine/albuterol))
+ adjust_albuterol_levels(amount)
+ final_total += amount
+
+ if (!(methods & INHALE))
+ return
+ if (istype(source_reagents.my_atom, /obj/item/cigarette)) // smoking is bad, kids
+ adjust_inflammation(inflammation_on_smoke * final_total * 5)
+
+/// Signal proc for when our asthma attack qdels. Unsets our refs to it and resets [next_attack_cooldown].
+/datum/quirk/item_quirk/asthma/proc/attack_deleting(datum/signal_source)
+ SIGNAL_HANDLER
+
+ UnregisterSignal(current_attack, COMSIG_QDELETING)
+ current_attack = null
+
+ COOLDOWN_START(src, next_attack_cooldown, rand(min_time_between_attacks, max_time_between_attacks))
+
+/// Signal handler for COMSIG_LIVING_POST_FULLY_HEAL. Heals our asthma.
+/datum/quirk/item_quirk/asthma/proc/on_full_heal(datum/signal_source, heal_flags)
+ SIGNAL_HANDLER
+
+ if (heal_flags & HEAL_ORGANS)
+ reset_asthma()
+
+/// Resets our asthma to normal. No inflammation, no pressure mult.
+/datum/quirk/item_quirk/asthma/proc/reset_asthma()
+ inflammation = 0
+ var/obj/item/organ/lungs/holder_lungs = quirk_holder.get_organ_slot(ORGAN_SLOT_LUNGS)
+ holder_lungs?.set_received_pressure_mult(holder_lungs::received_pressure_mult)
diff --git a/code/game/objects/effects/effect_system/fluid_spread/effects_smoke.dm b/code/game/objects/effects/effect_system/fluid_spread/effects_smoke.dm
index c9e68ce6ede..6d71edad180 100644
--- a/code/game/objects/effects/effect_system/fluid_spread/effects_smoke.dm
+++ b/code/game/objects/effects/effect_system/fluid_spread/effects_smoke.dm
@@ -124,6 +124,7 @@
smoker.smoke_delay = TRUE
addtimer(VARSET_CALLBACK(smoker, smoke_delay, FALSE), 1 SECONDS)
+ SEND_SIGNAL(smoker, COMSIG_CARBON_EXPOSED_TO_SMOKE, seconds_per_tick)
return TRUE
/**
diff --git a/code/game/objects/items/devices/scanners/health_analyzer.dm b/code/game/objects/items/devices/scanners/health_analyzer.dm
index 0da89a89252..d8033149045 100644
--- a/code/game/objects/items/devices/scanners/health_analyzer.dm
+++ b/code/game/objects/items/devices/scanners/health_analyzer.dm
@@ -432,6 +432,31 @@
Possible Cure: [disease.cure_text]\
"
+
+ // Lungs
+ var/obj/item/organ/lungs/lungs = target.get_organ_slot(ORGAN_SLOT_LUNGS)
+ if (lungs)
+ var/initial_pressure_mult = lungs::received_pressure_mult
+ if (lungs.received_pressure_mult != initial_pressure_mult)
+ var/tooltip
+ var/dilation_text
+ var/beginning_text = "Lung Dilation: "
+ if (lungs.received_pressure_mult > initial_pressure_mult) // higher than usual
+ beginning_text = span_blue("[beginning_text]")
+ dilation_text = span_blue("[(lungs.received_pressure_mult * 100) - 100]%")
+ tooltip = "Subject's lungs are dilated and breathing more air than usual. Increases the effectiveness of healium and other gases."
+ else
+ beginning_text = span_danger("[beginning_text]")
+ if (lungs.received_pressure_mult <= 0) // lethal
+ dilation_text = span_bolddanger("[lungs.received_pressure_mult * 100]%")
+ tooltip = "Subject's lungs are completely shut. Subject is unable to breathe and requires emergency surgery. If asthmatic, perform asthmatic bypass surgery and adminster albuterol inhalant. Otherwise, replace lungs."
+ else
+ dilation_text = span_danger("[lungs.received_pressure_mult * 100]%")
+ tooltip = "Subject's lungs are partially shut. If unable to breathe, administer a high-pressure internals tank or replace lungs. If asthmatic, inhaled albuterol or bypass surgery will likely help."
+
+ var/lung_message = beginning_text + conditional_tooltip(dilation_text, tooltip, TRUE)
+ render_list += lung_message
+
// Time of death
if(target.station_timestamp_timeofdeath && !target.appears_alive())
render_list += "
"
diff --git a/code/modules/mob/living/emote.dm b/code/modules/mob/living/emote.dm
index 1e9404f334d..8f1fd0b431d 100644
--- a/code/modules/mob/living/emote.dm
+++ b/code/modules/mob/living/emote.dm
@@ -398,6 +398,11 @@
return
return user.dna.species.get_cough_sound(user)
+/datum/emote/living/wheeze
+ key = "wheeze"
+ key_third_person = "wheezes"
+ message = "wheezes!"
+ emote_type = EMOTE_AUDIBLE
/datum/emote/living/pout
key = "pout"
diff --git a/code/modules/reagents/chemistry/reagents/medicine_reagents.dm b/code/modules/reagents/chemistry/reagents/medicine_reagents.dm
index 7f69044234d..53cc9d8bf2b 100644
--- a/code/modules/reagents/chemistry/reagents/medicine_reagents.dm
+++ b/code/modules/reagents/chemistry/reagents/medicine_reagents.dm
@@ -566,6 +566,87 @@
if(need_mob_update)
return UPDATE_MOB_HEALTH
+/datum/reagent/medicine/albuterol
+ name = "Albuterol"
+ description = "A potent bronchodilator capable of increasing the amount of gas inhaled by the lungs. Is highly effective at shutting down asthma attacks, \
+ but only when inhaled. Overdose causes over-dilation, resulting in reduced lung function. "
+ taste_description = "bitter and salty air"
+ overdose_threshold = 30
+ color = "#8df5f0"
+ metabolization_rate = REAGENTS_METABOLISM
+ ph = 4
+ chemical_flags = REAGENT_CAN_BE_SYNTHESIZED
+ default_container = /obj/item/reagent_containers/inhaler_canister
+
+ /// The decrement we will apply to the received_pressure_mult of our targets lungs.
+ var/pressure_mult_increment = 0.4
+ /// After this many cycles of overdose, we activate secondary effects.
+ var/secondary_overdose_effect_cycle_threshold = 40
+ /// We stop increasing stamina damage once we reach this number.
+ var/maximum_od_stamina_damage = 80
+
+/datum/reagent/medicine/albuterol/on_mob_metabolize(mob/living/affected_mob)
+ . = ..()
+
+ if (!iscarbon(affected_mob))
+ return
+
+ // has additional effects on asthma, but that's handled in the quirk
+
+ RegisterSignal(affected_mob, COMSIG_CARBON_LOSE_ORGAN, PROC_REF(holder_lost_organ))
+ RegisterSignal(affected_mob, COMSIG_CARBON_GAIN_ORGAN, PROC_REF(holder_gained_organ))
+ var/mob/living/carbon/carbon_mob = affected_mob
+ var/obj/item/organ/lungs/holder_lungs = carbon_mob.get_organ_slot(ORGAN_SLOT_LUNGS)
+ holder_lungs?.adjust_received_pressure_mult(pressure_mult_increment)
+
+/datum/reagent/medicine/albuterol/on_mob_end_metabolize(mob/living/affected_mob)
+ . = ..()
+
+ if (!iscarbon(affected_mob))
+ return
+
+ UnregisterSignal(affected_mob, list(COMSIG_CARBON_LOSE_ORGAN, COMSIG_CARBON_GAIN_ORGAN))
+ var/mob/living/carbon/carbon_mob = affected_mob
+ var/obj/item/organ/lungs/holder_lungs = carbon_mob.get_organ_slot(ORGAN_SLOT_LUNGS)
+ holder_lungs?.adjust_received_pressure_mult(-pressure_mult_increment)
+
+/datum/reagent/medicine/albuterol/overdose_process(mob/living/affected_mob, seconds_per_tick, times_fired)
+ . = ..()
+
+ if (!iscarbon(affected_mob))
+ return
+
+ var/mob/living/carbon/carbon_mob = affected_mob
+ if (SPT_PROB(25, seconds_per_tick))
+ carbon_mob.adjust_jitter_up_to(2 SECONDS, 20 SECONDS)
+ if (SPT_PROB(35, seconds_per_tick))
+ if (prob(60))
+ carbon_mob.losebreath += 1
+ to_chat(affected_mob, span_danger("Your diaphram spasms and you find yourself unable to breathe!"))
+ else
+ carbon_mob.breathe(seconds_per_tick, times_fired)
+ to_chat(affected_mob, span_danger("Your diaphram spasms and you unintentionally take a breath!"))
+
+ if (current_cycle > secondary_overdose_effect_cycle_threshold)
+ if (SPT_PROB(30, seconds_per_tick))
+ carbon_mob.adjust_eye_blur_up_to(6 SECONDS, 30 SECONDS)
+ if (carbon_mob.getStaminaLoss() < maximum_od_stamina_damage)
+ carbon_mob.adjustStaminaLoss(seconds_per_tick)
+
+/datum/reagent/medicine/albuterol/proc/holder_lost_organ(datum/source, obj/item/organ/lost)
+ SIGNAL_HANDLER
+
+ if (istype(lost, /obj/item/organ/lungs))
+ var/obj/item/organ/lungs/holder_lungs = lost
+ holder_lungs.adjust_received_pressure_mult(-pressure_mult_increment)
+
+/datum/reagent/medicine/albuterol/proc/holder_gained_organ(datum/source, obj/item/organ/gained)
+ SIGNAL_HANDLER
+
+ if (istype(gained, /obj/item/organ/lungs))
+ var/obj/item/organ/lungs/holder_lungs = gained
+ holder_lungs.adjust_received_pressure_mult(pressure_mult_increment)
+
/datum/reagent/medicine/ephedrine
name = "Ephedrine"
description = "Increases resistance to batons and movement speed, giving you hand cramps. Overdose deals toxin damage and inhibits breathing."
diff --git a/code/modules/reagents/chemistry/recipes/cat2_medicines.dm b/code/modules/reagents/chemistry/recipes/cat2_medicines.dm
index 802d5a820e2..8600f1ecdb7 100644
--- a/code/modules/reagents/chemistry/recipes/cat2_medicines.dm
+++ b/code/modules/reagents/chemistry/recipes/cat2_medicines.dm
@@ -179,7 +179,7 @@
H_ion_release = -1
rate_up_lim = 50
purity_min = 0.25
- reaction_flags = REACTION_PH_VOL_CONSTANT
+ reaction_flags = REACTION_PH_VOL_CONSTANT|REACTION_CLEAR_INVERSE
reaction_tags = REACTION_TAG_EASY | REACTION_TAG_HEALING | REACTION_TAG_OXY
/datum/chemical_reaction/medicine/convermol/reaction_step(datum/reagents/holder, datum/equilibrium/reaction, delta_t, delta_ph, step_reaction_vol)
diff --git a/code/modules/reagents/chemistry/recipes/medicine.dm b/code/modules/reagents/chemistry/recipes/medicine.dm
index 07d81164906..40639b2ec30 100644
--- a/code/modules/reagents/chemistry/recipes/medicine.dm
+++ b/code/modules/reagents/chemistry/recipes/medicine.dm
@@ -157,6 +157,51 @@
required_reagents = list(/datum/reagent/medicine/sal_acid = 1, /datum/reagent/lithium = 1, /datum/reagent/aluminium = 1, /datum/reagent/bromine = 1, /datum/reagent/ammonia = 1)
reaction_tags = REACTION_TAG_EASY | REACTION_TAG_HEALING | REACTION_TAG_OXY
+/datum/chemical_reaction/medicine/albuterol_creation
+ results = list(/datum/reagent/medicine/albuterol = 15)
+ required_reagents = list(/datum/reagent/lithium = 3, /datum/reagent/aluminium = 3, /datum/reagent/bromine = 3, /datum/reagent/inverse/healing/convermol = 1)
+ reaction_tags = REACTION_TAG_MODERATE | REACTION_TAG_ORGAN | REACTION_TAG_OTHER
+ required_temp = 400
+ optimal_temp = 600
+ overheat_temp = 900
+
+/datum/chemical_reaction/medicine/salbutamol_to_albuterol
+ results = list(/datum/reagent/medicine/albuterol = 4, /datum/reagent/medicine/sal_acid = 0.5, /datum/reagent/ammonia = 0.5)
+ required_catalysts = list(/datum/reagent/toxin/acid = 1)
+ required_reagents = list(/datum/reagent/medicine/salbutamol = 5, /datum/reagent/medicine/c2/convermol = 1)
+ reaction_tags = REACTION_TAG_MODERATE | REACTION_TAG_ORGAN | REACTION_TAG_OTHER
+ required_temp = 500
+ optimal_temp = 610
+ overheat_temp = 980
+ thermic_constant = 75
+ rate_up_lim = 10
+ mix_message = "The solution rapidly changes colors, boiling into a pale blue."
+
+/datum/chemical_reaction/medicine/albuterol_to_salbutamol
+ results = list(/datum/reagent/medicine/salbutamol = 2, /datum/reagent/ammonia = 1)
+ required_catalysts = list(/datum/reagent/toxin/acid = 1)
+ required_reagents = list(/datum/reagent/medicine/albuterol = 3, /datum/reagent/oxygen = 1)
+ reaction_tags = REACTION_TAG_EASY | REACTION_TAG_ORGAN | REACTION_TAG_OTHER
+ required_temp = 300
+ optimal_temp = 500
+ overheat_temp = 800
+ mix_message = "The solution breaks apart, turning a deeper blue."
+
+/datum/chemical_reaction/medicine/albuterol_to_inverse_convermol
+ results = list(/datum/reagent/inverse/healing/convermol = 1, /datum/reagent/lithium = 3, /datum/reagent/aluminium = 3, /datum/reagent/bromine = 3)
+ required_catalysts = list(/datum/reagent/toxin/acid/fluacid = 1)
+ required_reagents = list(/datum/reagent/medicine/albuterol = 5)
+ reaction_tags = REACTION_TAG_MODERATE | REACTION_TAG_ORGAN | REACTION_TAG_OTHER
+ required_temp = 900
+ optimal_temp = 920
+ overheat_temp = 990
+ thermic_constant = 25
+ mix_message = "The solution rapidly breaks apart, turning a mix of colors."
+
+/datum/chemical_reaction/medicine/albuterol_to_inverse_convermol/overheated(datum/reagents/holder, datum/equilibrium/equilibrium, impure = FALSE)
+ var/bonus = impure ? 2 : 1
+ explode_smoke(holder, equilibrium, 7.5 * bonus, TRUE, TRUE)
+
/datum/chemical_reaction/medicine/ephedrine
results = list(/datum/reagent/medicine/ephedrine = 4)
required_reagents = list(/datum/reagent/consumable/sugar = 1, /datum/reagent/fuel/oil = 1, /datum/reagent/hydrogen = 1, /datum/reagent/diethylamine = 1)
diff --git a/code/modules/reagents/reagent_containers/inhaler.dm b/code/modules/reagents/reagent_containers/inhaler.dm
new file mode 100644
index 00000000000..5600625df6c
--- /dev/null
+++ b/code/modules/reagents/reagent_containers/inhaler.dm
@@ -0,0 +1,314 @@
+/obj/item/inhaler
+ name = "inhaler"
+ desc = "A small device capable of administering short bursts of aerosolized chemicals. Requires a canister to function."
+ w_class = WEIGHT_CLASS_SMALL
+
+ icon = 'icons/obj/medical/chemical.dmi'
+ icon_state = "inhaler_generic"
+
+ custom_materials = list(/datum/material/plastic = SHEET_MATERIAL_AMOUNT * 0.1)
+
+ /// The currently installed canister, from which we get our reagents. Nullable.
+ var/obj/item/reagent_containers/inhaler_canister/canister
+ /// The path for our initial canister to be generated by. If not null, we start with that canister type.
+ var/obj/item/reagent_containers/inhaler_canister/initial_casister_path
+
+ /// The underlay of our canister, if one is installed.
+ var/mutable_appearance/canister_underlay
+ /// The y offset to be applied to [canister_underlay].
+ var/canister_underlay_y_offset = -2
+ /// If true, we will show a rotary display with how many puffs we can be used for until the canister runs out.
+ var/show_puffs_left = TRUE // this is how real inhalers work
+
+/obj/item/inhaler/Initialize(mapload)
+ . = ..()
+ if (ispath(initial_casister_path, /obj/item/reagent_containers/inhaler_canister))
+ set_canister(new initial_casister_path)
+
+/obj/item/inhaler/Destroy(force)
+ QDEL_NULL(canister)
+
+ return ..()
+
+/obj/item/inhaler/handle_deconstruct(disassembled)
+ . = ..()
+
+ canister?.forceMove(drop_location())
+
+/obj/item/inhaler/proc/update_canister_underlay()
+ if (isnull(canister))
+ underlays -= canister_underlay
+ canister_underlay = null
+ else if (isnull(canister_underlay))
+ canister_underlay = mutable_appearance(canister.icon, canister.icon_state)
+ canister_underlay.pixel_z = canister_underlay_y_offset
+ underlays += canister_underlay
+
+/obj/item/inhaler/examine(mob/user)
+ . = ..()
+
+ if (isnull(canister))
+ return
+
+ . += span_blue("It seems to have [canister] inserted.")
+ if (!show_puffs_left)
+ return
+
+ var/puffs_left = canister.get_puffs_left()
+ if (puffs_left > 0)
+ puffs_left = span_blue("[puffs_left]")
+ else
+ puffs_left = span_danger("[puffs_left]")
+ . += "Its rotary display shows its canister can be used [puffs_left] more times."
+
+/obj/item/inhaler/Exited(atom/movable/gone, direction)
+ . = ..()
+
+ if (gone == canister)
+ set_canister(null, move_canister = FALSE)
+
+
+/obj/item/inhaler/interact_with_atom(atom/interacting_with, mob/living/user, list/modifiers)
+ if (!isliving(interacting_with))
+ return ..() // default behavior
+ var/mob/living/target_mob = interacting_with
+
+ if (!can_puff(target_mob, user))
+ return NONE
+
+ var/puff_timer = 0
+
+ var/pre_use_visible_message
+ var/pre_use_self_message
+ var/pre_use_target_message
+
+ var/post_use_visible_message
+ var/post_use_self_message
+ var/post_use_target_message
+
+ if (target_mob == user) // no need for a target message
+ puff_timer = canister.self_administer_delay
+
+ pre_use_visible_message = span_notice("[user] puts [src] to [user.p_their()] lips, fingers on the canister...")
+ pre_use_self_message = span_notice("You put [src] to your lips and put pressure on the canister...")
+
+ post_use_visible_message = span_notice("[user] takes a puff of [src]!")
+ post_use_self_message = span_notice("You take a puff of [src]!")
+ else
+ puff_timer = canister.other_administer_delay
+
+ pre_use_visible_message = span_warning("[user] tries to force [src] between [target_mob]'s lips...")
+ pre_use_self_message = span_notice("You try to put [src] to [target_mob]'s lips...")
+ pre_use_target_message = span_userdanger("[user] tries to force [src] between your lips!")
+
+ post_use_visible_message = span_warning("[user] forces [src] between [target_mob]'s lips and pushes the canister down!")
+ post_use_self_message = span_notice("You force [src] between [target_mob]'s lips and press on the canister!")
+ post_use_target_message = span_userdanger("[user] forces [src] between your lips and presses on the canister, filling your lungs with aerosol!")
+
+ if (puff_timer > 0)
+ user.visible_message(pre_use_visible_message, ignored_mobs = list(user, target_mob))
+ to_chat(user, pre_use_self_message)
+ if (pre_use_target_message)
+ to_chat(target_mob, pre_use_target_message)
+ if (!do_after(user, puff_timer, src))
+ return NONE
+ if (!can_puff(target_mob, user)) // sanity
+ return NONE
+
+ user.visible_message(post_use_visible_message, ignored_mobs = list(user, target_mob))
+ to_chat(user, post_use_self_message)
+ if (post_use_target_message)
+ to_chat(target_mob, post_use_target_message)
+
+ canister.puff(user, target_mob)
+
+/obj/item/inhaler/attack_self(mob/user, modifiers)
+ try_remove_canister(user, modifiers)
+
+ return ..()
+
+/obj/item/inhaler/item_interaction(mob/living/user, obj/item/tool, list/modifiers)
+ if (istype(tool, /obj/item/reagent_containers/inhaler_canister))
+ return try_insert_canister(tool, user, modifiers)
+
+ return ..()
+
+/// Tries to remove the canister, if any is inserted.
+/obj/item/inhaler/proc/try_remove_canister(mob/living/user, modifiers)
+ if (isnull(canister))
+ balloon_alert(user, "no canister inserted!")
+ return FALSE
+
+ if (canister.removal_time > 0)
+ balloon_alert(user, "removing canister...")
+ if (!do_after(user, canister.removal_time, src))
+ return FALSE
+
+ balloon_alert(user, "canister removed")
+ playsound(src, canister.post_insert_sound, canister.post_insert_volume)
+ set_canister(null, user)
+
+// Tries to insert a canister, if none is already inserted.
+/obj/item/inhaler/proc/try_insert_canister(obj/item/reagent_containers/inhaler_canister/new_canister, mob/living/user, params)
+ if (!isnull(canister))
+ balloon_alert(user, "remove the existing canister!")
+ return FALSE
+
+ balloon_alert(user, "inserting canister...")
+ playsound(src, new_canister.pre_insert_sound, new_canister.pre_insert_volume)
+ if (!do_after(user, new_canister.insertion_time, src))
+ return FALSE
+ playsound(src, new_canister.post_insert_sound, new_canister.post_insert_volume)
+ balloon_alert(user, "canister inserted")
+ set_canister(new_canister, user)
+
+ return TRUE
+
+/// Setter proc for [canister]. Moves the existing canister out of the inhaler, while moving a new canister inside and registering it.
+/obj/item/inhaler/proc/set_canister(obj/item/reagent_containers/inhaler_canister/new_canister, mob/living/user, move_canister = TRUE)
+ if (move_canister && !isnull(canister))
+ if (iscarbon(loc))
+ var/mob/living/carbon/carbon_loc = loc
+ INVOKE_ASYNC(carbon_loc, TYPE_PROC_REF(/mob/living/carbon, put_in_hands), canister)
+ else if (!isnull(loc))
+ canister.forceMove(loc)
+
+ canister = new_canister
+ canister?.forceMove(src)
+ update_canister_underlay()
+
+/// Determines if we can be used. Fails on no canister, empty canister, invalid targets, or non-breathing targets.
+/obj/item/inhaler/proc/can_puff(mob/living/target_mob, mob/living/user, silent = FALSE)
+ if (isnull(canister))
+ if (!silent)
+ balloon_alert(user, "no canister!")
+ return FALSE
+ if (isnull(canister.reagents) || canister.reagents.total_volume <= 0)
+ if (!silent)
+ balloon_alert(user, "canister is empty!")
+ return FALSE
+ if (!iscarbon(target_mob)) // maybe mix this into a general has mouth check
+ if (!silent)
+ balloon_alert(user, "not breathing!")
+ return FALSE
+ var/mob/living/carbon/carbon_target = target_mob
+ if (carbon_target.is_mouth_covered())
+ if (!silent)
+ balloon_alert(user, "expose the mouth!")
+ return FALSE
+ if (HAS_TRAIT(carbon_target, TRAIT_NOBREATH))
+ if (!silent)
+ balloon_alert(user, "not breathing!")
+ return FALSE
+ var/obj/item/organ/lungs/lungs = carbon_target.get_organ_slot(ORGAN_SLOT_LUNGS)
+ if (isnull(lungs) || lungs.received_pressure_mult <= 0)
+ if (!silent)
+ balloon_alert(user, "not breathing!")
+ return FALSE
+
+ return TRUE
+
+/obj/item/reagent_containers/inhaler_canister
+ name = "inhaler canister"
+ desc = "A small canister filled with aerosolized reagents for use in a inhaler."
+ w_class = WEIGHT_CLASS_TINY
+
+ icon = 'icons/obj/medical/chemical.dmi'
+ icon_state = "canister_generic"
+
+ reagent_flags = SEALED_CONTAINER|DRAINABLE|REFILLABLE
+ has_variable_transfer_amount = FALSE
+
+ max_integrity = 60
+
+ custom_materials = list(/datum/material/iron = SHEET_MATERIAL_AMOUNT * 0.2)
+
+ /// The sound that plays when we are used.
+ var/puff_sound = 'sound/effects/spray.ogg'
+ /// The volume of [puff_sound]
+ var/puff_volume = 20
+
+ /// The sound that plays when someone TRIES to insert us.
+ var/pre_insert_sound = 'sound/items/taperecorder/tape_flip.ogg'
+ /// The sound that plays when we are removed or inserted.
+ var/post_insert_sound = 'sound/items/taperecorder/taperecorder_close.ogg'
+
+ /// The volume of [pre_insert_sound]
+ var/pre_insert_volume = 50
+ /// The volume of [post_insert_sound]
+ var/post_insert_volume = 50
+
+ /// The time it takes to insert us into a inhaler.
+ var/insertion_time = 2 SECONDS
+ /// The time it takes to remove us from a inhaler.
+ var/removal_time = 0.5 SECONDS
+
+ /// The time it takes for us to be used on someone else.
+ var/other_administer_delay = 3 SECONDS
+ /// The time it takes for us to be used on our owner.
+ var/self_administer_delay = 1 SECONDS
+
+/// Called when a inhaler we are in is used on someone. Transfers reagents and plays the puff sound.
+/obj/item/reagent_containers/inhaler_canister/proc/puff(mob/living/user, mob/living/carbon/target)
+ playsound(src, puff_sound, puff_volume, TRUE, -6)
+ reagents.trans_to(target, amount_per_transfer_from_this, transferred_by = user, methods = INHALE)
+
+/// Returns a integer approximating how many puffs we can be used for.
+/obj/item/reagent_containers/inhaler_canister/proc/get_puffs_left()
+ return ROUND_UP(reagents.total_volume / amount_per_transfer_from_this)
+
+/obj/item/reagent_containers/inhaler_canister/handle_deconstruct(disassembled)
+ if (!reagents?.total_volume)
+ return ..()
+
+ var/datum/reagents/smoke_reagents = new/datum/reagents() // Lets be safe first, our own reagents may be qdelled if we get deleted
+ var/datum/effect_system/fluid_spread/smoke/chem/smoke_machine/smoke = new()
+ smoke_reagents.my_atom = src
+ for (var/datum/reagent/reagent as anything in reagents.reagent_list)
+ smoke_reagents.add_reagent(reagent.type, reagent.volume, added_purity = reagent.purity)
+ reagents.remove_reagent(reagent.type, reagent.volume)
+ if (smoke_reagents.reagent_list)
+ smoke.set_up(1, holder = src, location = get_turf(src), carry = smoke_reagents)
+ smoke.start(log = TRUE)
+ visible_message(span_warning("[src] breaks open and sprays its aerosilized contents everywhere!"))
+ else
+ visible_message(span_warning("[src] breaks open - but is empty!"))
+
+ return ..()
+
+/obj/item/inhaler/medical
+ icon_state = "inhaler_medical"
+
+/obj/item/inhaler/salbutamol
+ name = "salbutamol inhaler"
+ icon_state = "inhaler_medical"
+ initial_casister_path = /obj/item/reagent_containers/inhaler_canister/salbutamol
+
+/obj/item/reagent_containers/inhaler_canister/salbutamol
+ name = "salbutamol canister"
+ icon_state = "canister_medical"
+ list_reagents = list(/datum/reagent/medicine/salbutamol = 30)
+
+/obj/item/inhaler/albuterol
+ name = "albuterol inhaler"
+ icon_state = "inhaler_medical"
+ initial_casister_path = /obj/item/reagent_containers/inhaler_canister/albuterol
+
+/obj/item/reagent_containers/inhaler_canister/albuterol
+ name = "albuterol canister"
+ desc = "A small canister filled with aerosolized reagents for use in a inhaler. This one contains albuterol, a potent bronchodilator that can stop \
+ asthma attacks in their tracks."
+ icon_state = "canister_medical"
+ list_reagents = list(/datum/reagent/medicine/albuterol = 30)
+
+/obj/item/reagent_containers/inhaler_canister/albuterol/asthma
+ name = "low-pressure albuterol canister"
+ desc = "A small canister filled with aerosolized reagents for use in a inhaler. This one contains albuterol, a potent bronchodilator that can stop \
+ asthma attacks in their tracks. It seems to be a lower-pressure variant, and can only hold 20u."
+ list_reagents = list(/datum/reagent/medicine/albuterol = 20)
+ volume = 20
+
+/obj/item/inhaler/albuterol/asthma
+ name = "rescue inhaler"
+ icon_state = "inhaler_generic"
+ initial_casister_path = /obj/item/reagent_containers/inhaler_canister/albuterol/asthma
diff --git a/code/modules/research/designs/medical_designs.dm b/code/modules/research/designs/medical_designs.dm
index 9cfab3b3f8a..e4a8b78a6e7 100644
--- a/code/modules/research/designs/medical_designs.dm
+++ b/code/modules/research/designs/medical_designs.dm
@@ -128,6 +128,30 @@
)
departmental_flags = DEPARTMENT_BITFLAG_MEDICAL | DEPARTMENT_BITFLAG_SCIENCE
+/datum/design/inhaler
+ name = "Inhaler"
+ desc = "A small device capable of administering short bursts of aerosolized chemicals. Requires a canister to function."
+ id = "inhaler"
+ build_path = /obj/item/inhaler/medical
+ build_type = PROTOLATHE | AWAY_LATHE
+ materials = list(/datum/material/plastic = SHEET_MATERIAL_AMOUNT * 0.1)
+ category = list(
+ RND_CATEGORY_EQUIPMENT + RND_SUBCATEGORY_EQUIPMENT_MEDICAL
+ )
+ departmental_flags = DEPARTMENT_BITFLAG_MEDICAL
+
+/datum/design/inhaler_canister
+ name = "Inhaler Canister"
+ desc = "A small canister filled with aerosolized reagents for use in a inhaler."
+ id = "inhaler_canister"
+ build_path = /obj/item/reagent_containers/inhaler_canister
+ build_type = PROTOLATHE | AWAY_LATHE
+ materials = list(/datum/material/iron = SHEET_MATERIAL_AMOUNT * 0.2)
+ category = list(
+ RND_CATEGORY_EQUIPMENT + RND_SUBCATEGORY_EQUIPMENT_MEDICAL
+ )
+ departmental_flags = DEPARTMENT_BITFLAG_MEDICAL
+
/datum/design/bluespacebodybag
name = "Bluespace Body Bag"
desc = "A bluespace body bag, powered by experimental bluespace technology. It can hold loads of bodies and the largest of creatures."
diff --git a/code/modules/research/techweb/nodes/medbay_nodes.dm b/code/modules/research/techweb/nodes/medbay_nodes.dm
index 0e16f9cacbd..49d4aad71f7 100644
--- a/code/modules/research/techweb/nodes/medbay_nodes.dm
+++ b/code/modules/research/techweb/nodes/medbay_nodes.dm
@@ -49,6 +49,8 @@
prereq_ids = list(TECHWEB_NODE_MEDBAY_EQUIP)
design_ids = list(
"med_spray_bottle",
+ "inhaler",
+ "inhaler_canister",
"medigel",
"medipen_refiller",
"soda_dispenser",
diff --git a/code/modules/surgery/asthmatic_bypass.dm b/code/modules/surgery/asthmatic_bypass.dm
new file mode 100644
index 00000000000..7e69c99a7bd
--- /dev/null
+++ b/code/modules/surgery/asthmatic_bypass.dm
@@ -0,0 +1,96 @@
+/datum/surgery/asthmatic_bypass
+ name = "Asthmatic Bypass"
+ surgery_flags = SURGERY_REQUIRE_RESTING | SURGERY_REQUIRE_LIMB
+ requires_bodypart_type = NONE
+ organ_to_manipulate = ORGAN_SLOT_LUNGS
+ possible_locs = list(BODY_ZONE_CHEST)
+ steps = list(
+ /datum/surgery_step/incise,
+ /datum/surgery_step/retract_skin,
+ /datum/surgery_step/clamp_bleeders,
+ /datum/surgery_step/incise,
+ /datum/surgery_step/expand_windpipe,
+ /datum/surgery_step/close,
+ )
+
+/datum/surgery/asthmatic_bypass/can_start(mob/user, mob/living/patient)
+ . = ..()
+
+ if (!.)
+ return
+
+ return (patient.has_quirk(/datum/quirk/item_quirk/asthma))
+
+/datum/surgery_step/expand_windpipe
+ name = "force open windpipe (retractor)"
+ implements = list(
+ TOOL_RETRACTOR = 80,
+ TOOL_WIRECUTTER = 45,
+ )
+ time = 8 SECONDS
+ repeatable = TRUE
+ preop_sound = 'sound/items/handling/surgery/retractor1.ogg'
+ success_sound = 'sound/items/handling/surgery/retractor2.ogg'
+
+ /// The amount of inflammation a failure or success of this surgery will reduce.
+ var/inflammation_reduction = 75
+
+/datum/surgery_step/expand_windpipe/preop(mob/user, mob/living/target, target_zone, obj/item/tool, datum/surgery/surgery)
+ display_results(
+ user,
+ target,
+ span_notice("You start to stretch [target]'s windpipe, trying your best to avoid nearby blood vessels..."),
+ span_notice("[user] begins to stretch [target]'s windpipe, taking care to avoid any nearby blood vessels."),
+ span_notice("[user] begins to stretch [target]'s windpipe."),
+ )
+ display_pain(target, "You feel an agonizing stretching sensation in your neck!")
+
+/datum/surgery_step/expand_windpipe/success(mob/user, mob/living/target, target_zone, obj/item/tool, datum/surgery/surgery, default_display_results = TRUE)
+ if (!reduce_inflammation(user, target, tool, surgery))
+ return
+
+ default_display_results = FALSE
+ display_results(
+ user,
+ target,
+ span_notice("You stretch [target]'s windpipe with [tool], managing to avoid the nearby blood vessels and arteries."),
+ span_notice("[user] succeeds at stretching [target]'s windpipe with [tool], avoiding the nearby blood vessels and arteries."),
+ span_notice("[user] finishes stretching [target]'s windpipe.")
+ )
+
+ return ..()
+
+/datum/surgery_step/expand_windpipe/failure(mob/user, mob/living/target, target_zone, obj/item/tool, datum/surgery/surgery, fail_prob)
+ if (!reduce_inflammation(user, target, tool, surgery))
+ return
+
+ display_results(
+ user,
+ target,
+ span_bolddanger("You stretch [target]'s windpipe with [tool], but accidentally clip a few arteries!"),
+ span_bolddanger("[user] succeeds at stretching [target]'s windpipe with [tool], but accidentally clips a few arteries!"),
+ span_bolddanger("[user] finishes stretching [target]'s windpipe, but screws up!")
+ )
+
+ target.losebreath++
+
+ if (iscarbon(target))
+ var/mob/living/carbon/carbon_patient = target
+ var/wound_bonus = tool.wound_bonus
+ var/obj/item/bodypart/head/patient_chest = carbon_patient.get_bodypart(BODY_ZONE_CHEST)
+ if (patient_chest)
+ if (prob(30))
+ carbon_patient.cause_wound_of_type_and_severity(WOUND_SLASH, patient_chest, WOUND_SEVERITY_MODERATE, WOUND_SEVERITY_CRITICAL, WOUND_PICK_LOWEST_SEVERITY, tool)
+ patient_chest.receive_damage(brute = 10, wound_bonus = wound_bonus, sharpness = SHARP_EDGED, damage_source = tool)
+
+ return FALSE
+
+/// Reduces the asthmatic's inflammation by [inflammation_reduction]. Called by both success and failure.
+/datum/surgery_step/expand_windpipe/proc/reduce_inflammation(mob/user, mob/living/target, obj/item/tool, datum/surgery/surgery)
+ var/datum/quirk/item_quirk/asthma/asthma_quirk = locate(/datum/quirk/item_quirk/asthma) in target.quirks
+ if (isnull(asthma_quirk))
+ qdel(surgery) // not really an error cause quirks can get removed during surgery?
+ return FALSE
+
+ asthma_quirk.adjust_inflammation(-inflammation_reduction)
+ return TRUE
diff --git a/code/modules/surgery/organs/_organ.dm b/code/modules/surgery/organs/_organ.dm
index ac602413eee..54de85f5467 100644
--- a/code/modules/surgery/organs/_organ.dm
+++ b/code/modules/surgery/organs/_organ.dm
@@ -324,6 +324,7 @@ INITIALIZE_IMMEDIATE(/obj/item/organ)
lungs = new()
lungs.Insert(src)
lungs.set_organ_damage(0)
+ lungs.received_pressure_mult = lungs::received_pressure_mult
var/obj/item/organ/heart/heart = get_organ_slot(ORGAN_SLOT_HEART)
if(heart)
diff --git a/code/modules/surgery/organs/internal/lungs/_lungs.dm b/code/modules/surgery/organs/internal/lungs/_lungs.dm
index 27a88a6d94e..c15dd74a263 100644
--- a/code/modules/surgery/organs/internal/lungs/_lungs.dm
+++ b/code/modules/surgery/organs/internal/lungs/_lungs.dm
@@ -69,6 +69,9 @@
var/n2o_euphoria = EUPHORIA_LAST_FLAG
var/healium_euphoria = EUPHORIA_LAST_FLAG
+ /// All incoming breaths will have their pressure multiplied against this. Higher values allow more air to be breathed at once,
+ /// while lower values can cause suffocation in low pressure environments.
+ var/received_pressure_mult = 1
var/oxy_breath_dam_min = MIN_TOXIC_GAS_DAMAGE
var/oxy_breath_dam_max = MAX_TOXIC_GAS_DAMAGE
@@ -168,6 +171,7 @@
receiver.clear_alert(ALERT_NOT_ENOUGH_NITRO)
receiver.clear_alert(ALERT_NOT_ENOUGH_PLASMA)
receiver.clear_alert(ALERT_NOT_ENOUGH_N2O)
+ update_bronchodilation_alerts()
/obj/item/organ/lungs/on_mob_remove(mob/living/carbon/organ_owner, special, movement_flags)
. = ..()
@@ -646,7 +650,7 @@
// Build out our partial pressures, for use as we go
var/list/partial_pressures = list()
for(var/gas_id in breath_gases)
- partial_pressures[gas_id] = breath.get_breath_partial_pressure(breath_gases[gas_id][MOLES])
+ partial_pressures[gas_id] = breath.get_breath_partial_pressure(breath_gases[gas_id][MOLES] * received_pressure_mult)
// Treat gas as other types of gas
for(var/list/conversion_packet in treat_as)
@@ -1044,6 +1048,38 @@
#undef GAS_TOLERANCE
+/// Adjusting proc for [received_pressure_mult]. Updates bronchodilation alerts.
+/obj/item/organ/lungs/proc/adjust_received_pressure_mult(adjustment)
+ received_pressure_mult = max(received_pressure_mult + adjustment, 0)
+ update_bronchodilation_alerts()
+
+/// Setter proc for [received_pressure_mult]. Updates bronchodilation alerts.
+/obj/item/organ/lungs/proc/set_received_pressure_mult(new_value)
+ received_pressure_mult = max(new_value, 0)
+ update_bronchodilation_alerts()
+
+#define LUNG_CAPACITY_ALERT_BUFFER 0.003
+/// Depending on [received_pressure_mult], gives either a bronchocontraction or bronchoconstriction alert to our owner (if we have one), or clears the alert
+/// if [received_pressure_mult] is near 1.
+/obj/item/organ/lungs/proc/update_bronchodilation_alerts()
+ if (!owner)
+ return
+
+ var/initial_value = initial(received_pressure_mult)
+
+ // you wont really notice if youre only breathing a bit more or a bit less
+ var/dilated = (received_pressure_mult > (initial_value + LUNG_CAPACITY_ALERT_BUFFER))
+ var/constricted = (received_pressure_mult < (initial_value - LUNG_CAPACITY_ALERT_BUFFER))
+
+ if (dilated)
+ owner.throw_alert(ALERT_BRONCHODILATION, /atom/movable/screen/alert/bronchodilated)
+ else if (constricted)
+ owner.throw_alert(ALERT_BRONCHODILATION, /atom/movable/screen/alert/bronchoconstricted)
+ else
+ owner.clear_alert(ALERT_BRONCHODILATION)
+
+#undef LUNG_CAPACITY_ALERT_BUFFER
+
/obj/item/organ/lungs/ethereal
name = "aeration reticulum"
desc = "These exotic lungs seem crunchier than most."
diff --git a/code/modules/vending/medical.dm b/code/modules/vending/medical.dm
index 21cabf95dd3..c94bc440663 100644
--- a/code/modules/vending/medical.dm
+++ b/code/modules/vending/medical.dm
@@ -103,6 +103,7 @@
/obj/item/reagent_containers/medigel/synthflesh = 2,
/obj/item/storage/pill_bottle/psicodine = 2,
/obj/item/storage/pill_bottle/sansufentanyl = 1,
+ /obj/item/inhaler/albuterol = 2,
)
default_price = 50
extra_price = 100
diff --git a/icons/hud/screen_alert.dmi b/icons/hud/screen_alert.dmi
index d308aea2e2f..58aa910cb22 100644
Binary files a/icons/hud/screen_alert.dmi and b/icons/hud/screen_alert.dmi differ
diff --git a/icons/obj/medical/chemical.dmi b/icons/obj/medical/chemical.dmi
index 9b785b3ed60..a7e9cfa48c2 100644
Binary files a/icons/obj/medical/chemical.dmi and b/icons/obj/medical/chemical.dmi differ
diff --git a/tgstation.dme b/tgstation.dme
index a173c28cbe0..6b0618f228a 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -1402,6 +1402,7 @@
#include "code\datums\diseases\adrenal_crisis.dm"
#include "code\datums\diseases\anaphylaxis.dm"
#include "code\datums\diseases\anxiety.dm"
+#include "code\datums\diseases\asthma_attack.dm"
#include "code\datums\diseases\beesease.dm"
#include "code\datums\diseases\brainrot.dm"
#include "code\datums\diseases\chronic_illness.dm"
@@ -1818,6 +1819,7 @@
#include "code\datums\quirks\negative_quirks\all_nighter.dm"
#include "code\datums\quirks\negative_quirks\allergic.dm"
#include "code\datums\quirks\negative_quirks\anosmia.dm"
+#include "code\datums\quirks\negative_quirks\asthma.dm"
#include "code\datums\quirks\negative_quirks\bad_back.dm"
#include "code\datums\quirks\negative_quirks\bad_touch.dm"
#include "code\datums\quirks\negative_quirks\big_hands.dm"
@@ -5973,6 +5975,7 @@
#include "code\modules\reagents\reagent_containers\cooler_jug.dm"
#include "code\modules\reagents\reagent_containers\dropper.dm"
#include "code\modules\reagents\reagent_containers\hypospray.dm"
+#include "code\modules\reagents\reagent_containers\inhaler.dm"
#include "code\modules\reagents\reagent_containers\jerrycan.dm"
#include "code\modules\reagents\reagent_containers\medigel.dm"
#include "code\modules\reagents\reagent_containers\patch.dm"
@@ -6260,6 +6263,7 @@
#include "code\modules\station_goals\station_goal.dm"
#include "code\modules\station_goals\vault_mutation.dm"
#include "code\modules\surgery\amputation.dm"
+#include "code\modules\surgery\asthmatic_bypass.dm"
#include "code\modules\surgery\autopsy.dm"
#include "code\modules\surgery\blood_filter.dm"
#include "code\modules\surgery\bone_mending.dm"