Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using Content.IntegrationTests.Fixtures;
using Content.IntegrationTests.Fixtures.Attributes;
using Content.Server.Backmen.Surgery.Pain.Systems;
using Content.Server.Body.Systems;
using Content.Shared.Backmen.CCVar;
using Content.Shared.Backmen.Surgery.Pain.Components;
using Content.Shared.Body.Components;
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.Reagent;
using Content.Shared.FixedPoint;
using Content.Shared.StatusEffectNew;
using Robust.Shared.GameObjects;
using Robust.Shared.Prototypes;

namespace Content.IntegrationTests.Tests.Backmen.Medical;

[TestFixture]
[EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.PainEnabled), true)]
public sealed class MorphazinePainImmuneTest : GameTest
{
private static readonly EntProtoId MobHuman = "MobHuman";
private static readonly EntProtoId PainImmuneEffect = "StatusEffectPainImmune";
private static readonly ProtoId<ReagentPrototype> MorphazineReagent = "Morphazine";

public override PoolSettings PoolSettings => new()
{
Connected = false,
Dirty = true,
};

[Test]
public async Task StatusEffect_GrantsPainImmuneWithoutComponentOnMob()
{
var map = await Pair.CreateTestMap();
EntityUid human = default;

await Server.WaitPost(() =>
{
human = Server.EntMan.SpawnAtPosition(MobHuman, map.GridCoords);
var statusSys = Server.EntMan.System<StatusEffectsSystem>();
Assert.That(
statusSys.TryUpdateStatusEffectDuration(human, PainImmuneEffect, TimeSpan.FromSeconds(30)),
Is.True,
"StatusEffectPainImmune should apply to a human.");
});

await Pair.RunTicksSync(5);

await Server.WaitAssertion(() =>
{
var painSys = Server.EntMan.System<ServerPainSystem>();
Assert.That(
Server.EntMan.HasComponent<PainImmuneComponent>(human),
Is.False,
"PainImmune must stay on the status effect, not the mob.");
Assert.That(painSys.IsPainImmune(human), Is.True, "Helper should see PainImmune on the status effect.");
});

await Server.WaitPost(() =>
{
var statusSys = Server.EntMan.System<StatusEffectsSystem>();
Assert.That(statusSys.TryRemoveStatusEffect(human, PainImmuneEffect), Is.True);
});

await Pair.RunTicksSync(5);

await Server.WaitAssertion(() =>
{
var painSys = Server.EntMan.System<ServerPainSystem>();
Assert.That(painSys.IsPainImmune(human), Is.False, "Helper should be false after the status effect is removed.");
});
}

[Test]
public async Task Morphazine_AppliesPainImmuneStatusEffect()
{
var map = await Pair.CreateTestMap();
var bloodstreamSys = Server.EntMan.System<BloodstreamSystem>();
EntityUid human = default;

await Server.WaitPost(() =>
{
human = Server.EntMan.SpawnAtPosition(MobHuman, map.GridCoords);
Assert.That(Server.EntMan.TryGetComponent(human, out BloodstreamComponent? stream), Is.True);
var solution = new Solution();
// Stay above the crash threshold (max 2u) so the test doesn't immediately sleep.
solution.AddReagent(MorphazineReagent, FixedPoint2.New(10));
Assert.That(bloodstreamSys.TryAddToBloodstream((human, stream), solution), Is.True);
});

await Pair.RunTicksSync(90);

await Server.WaitAssertion(() =>
{
var painSys = Server.EntMan.System<ServerPainSystem>();
var statusSys = Server.EntMan.System<StatusEffectsSystem>();
Assert.That(
Server.EntMan.HasComponent<PainImmuneComponent>(human),
Is.False,
"Morphazine must not copy PainImmune onto the mob.");
Assert.That(
statusSys.HasStatusEffect(human, PainImmuneEffect),
Is.True,
"Morphazine should apply StatusEffectPainImmune while metabolizing.");
Assert.That(painSys.IsPainImmune(human), Is.True, "Morphazine should make IsPainImmune true via the status effect.");
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ public sealed partial class ServerPainSystem : PainSystem

[Dependency] private ConsciousnessSystem _consciousness = default!;
[Dependency] private WoundSystem _wound = default!;
[Dependency] private EntityQuery<PainImmuneComponent> _painImmuneQuery = default!;

private const string PainAdrenalineIdentifier = "PainAdrenaline";
private const string PainPhantomPainIdentifier = "PhantomPain";
Expand Down Expand Up @@ -560,7 +559,7 @@ private void UpdateNerveSystemPain(EntityUid uid, NerveSystemComponent? nerveSys

private void UpdatePainThreshold(EntityUid uid, EntityUid body, NerveSystemComponent nerveSys)
{
if (_painImmuneQuery.HasComp(body))
if (IsPainImmune(body))
return;

var painInput = nerveSys.Pain - nerveSys.LastPainThreshold;
Expand Down
7 changes: 3 additions & 4 deletions Content.Server/Medical/HealthAnalyzerSystem.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using Content.Server.Medical.Components;
using Content.Shared.Backmen.Surgery.Pain.Components;
using Content.Shared.Backmen.Surgery.Pain.Systems;
using Content.Shared.Backmen.Targeting;
using Content.Shared.Body;
using Content.Shared.Body.Components;
Expand Down Expand Up @@ -61,10 +61,9 @@ public sealed partial class HealthAnalyzerSystem : EntitySystem
[Dependency] private SharedPopupSystem _popupSystem = default!;
[Dependency] private SharedBloodstreamSystem _bloodstreamSystem = default!;
[Dependency] private ServerConsciousnessSystem _consciousnessSystem = default!; // backmen: pain
[Dependency] private PainSystem _pain = default!; // backmen: pain
[Dependency] private HungerSystem _hungerSystem = default!; // backmen: analyzer-satiation
[Dependency] private ThirstSystem _thirstSystem = default!; // backmen: analyzer-satiation

[Dependency] private EntityQuery<PainImmuneComponent> _painImmuneQuery = default!;
// start-backmen: analyzer-authoritative-damage
[Dependency] private EntityQuery<ConsciousnessComponent> _consciousnessQuery = default!;
[Dependency] private DamageableSystem _damageable = default!;
Expand Down Expand Up @@ -292,7 +291,7 @@ public HealthAnalyzerUiState GetHealthAnalyzerUiState(EntityUid? target, EntityU

var painCauses = _consciousnessSystem.GetPainCauses(entity);
var totalPain = _consciousnessSystem.GetTotalPain(entity);
var painImmune = _painImmuneQuery.HasComp(entity);
var painImmune = _pain.IsPainImmune(entity);

// start-backmen: analyzer-satiation
var hungerLevel = float.NaN;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Linq;
using Content.Shared.Backmen.Surgery.Consciousness.Components;
using Content.Shared.Backmen.Surgery.Pain.Components;
using Content.Shared.Backmen.Surgery.Pain.Systems;
using Content.Shared.Backmen.Surgery.Wounds.Systems;
using Content.Shared.Backmen.Body.Systems;
Expand Down Expand Up @@ -32,7 +31,6 @@ public abstract partial class ConsciousnessSystem : EntitySystem

[Dependency] protected EntityQuery<ConsciousnessComponent> ConsciousnessQuery = default!;
[Dependency] protected EntityQuery<MobStateComponent> MobStateQuery = default!;
[Dependency] protected EntityQuery<PainImmuneComponent> PainImmuneQuery = default!;

public override void Initialize()
{
Expand Down Expand Up @@ -152,7 +150,7 @@ protected void UpdateMobState(
if (HasComp<ZombieComponent>(target.Owner))
return;

var inPainCrit = !PainImmuneQuery.HasComp(target)
var inPainCrit = !Pain.IsPainImmune(target)
&& TryGetNerveSystem(target, out var nerveSys)
&& (nerveSys.Value.Comp.Pain >= nerveSys.Value.Comp.SoftPainCap
|| nerveSys.Value.Comp.ForcePainCrit);
Expand Down
39 changes: 36 additions & 3 deletions Content.Shared/Backmen/Surgery/Pain/Systems/PainSystem.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using System.Linq;
using Content.Shared.Backmen.Surgery.Pain.Components;
using Content.Shared.HealthExaminable;
using Content.Shared.StatusEffectNew;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Containers;
using Robust.Shared.Timing;
using Robust.Shared.Utility;

namespace Content.Shared.Backmen.Surgery.Pain.Systems;

Expand All @@ -13,9 +16,12 @@ public abstract partial class PainSystem : EntitySystem
[Dependency] protected IConfigurationManager Cfg = default!;

[Dependency] protected SharedAudioSystem IHaveNoMouthAndIMustScream = default!;
[Dependency] private StatusEffectsSystem _statusEffects = default!;
[Dependency] private SharedContainerSystem _container = default!;

protected EntityQuery<NerveSystemComponent> NerveSystemQuery;
protected EntityQuery<NerveOrganComponent> NerveQuery;
protected EntityQuery<PainImmuneComponent> PainImmuneQuery;

public override void Initialize()
{
Expand All @@ -25,17 +31,44 @@ public override void Initialize()
SubscribeLocalEvent<NerveSystemComponent, EntityTerminatingEvent>(OnNerveSystemTerminating);
SubscribeLocalEvent<NerveOrganComponent, AfterAutoHandleStateEvent>(OnNerveAfterAutoHandleState);
SubscribeLocalEvent<PainImmuneComponent, HealthBeingExaminedEvent>(OnPainImmuneHealthExamined);
SubscribeLocalEvent<PainImmuneComponent, StatusEffectRelayedEvent<HealthBeingExaminedEvent>>(OnPainImmuneHealthExaminedRelayed);

NerveSystemQuery = GetEntityQuery<NerveSystemComponent>();
NerveQuery = GetEntityQuery<NerveOrganComponent>();
PainImmuneQuery = GetEntityQuery<PainImmuneComponent>();
}

/// <summary>
/// True if the entity has inherent <see cref="PainImmuneComponent"/> or a status effect that carries it.
/// </summary>
public bool IsPainImmune(EntityUid uid)
{
return PainImmuneQuery.HasComp(uid) || _statusEffects.HasEffectComp<PainImmuneComponent>(uid);
}

private void OnPainImmuneHealthExamined(Entity<PainImmuneComponent> ent, ref HealthBeingExaminedEvent args)
{
if (!args.Message.IsEmpty)
args.Message.PushNewline();
AddPainImmuneExamineText(args.Message, ent.Owner);
}

private void OnPainImmuneHealthExaminedRelayed(Entity<PainImmuneComponent> ent, ref StatusEffectRelayedEvent<HealthBeingExaminedEvent> args)
{
if (!_container.TryGetContainingContainer((ent.Owner, null, null), out var container))
return;

var target = container.Owner;
if (PainImmuneQuery.HasComp(target))
return;

AddPainImmuneExamineText(args.Args.Message, target);
}

private void AddPainImmuneExamineText(FormattedMessage message, EntityUid target)
{
if (!message.IsEmpty)
message.PushNewline();

args.Message.TryAddMarkup(Loc.GetString("pain-immune-health-examine", ("target", ent.Owner)), out _);
message.TryAddMarkup(Loc.GetString("pain-immune-health-examine", ("target", target)), out _);
}

private void OnNerveSystemTerminating(Entity<NerveSystemComponent> ent, ref EntityTerminatingEvent args)
Expand Down
10 changes: 9 additions & 1 deletion Content.Shared/Medical/Healing/HealingSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ private void OnBodyDoAfter(EntityUid ent, BodyComponent comp, ref HealingDoAfter
// Prefer the next best part for the repeated do-after tick.
if (_medicalTarget.TryResolveHealTarget(ent, args.User, healing, out var nextWoundable, out _, out _))
args.TargetWoundable = GetNetEntity(nextWoundable);

// Update our self heal delay so it shortens as we heal more damage.
if (args.User == ent)
args.Args.Delay = healing.Delay * GetScaledHealingPenalty(ent, healing.SelfHealPenaltyMultiplier);
return;
}

Expand Down Expand Up @@ -562,10 +566,14 @@ public float GetScaledHealingPenalty(Entity<DamageableComponent?, MobThresholdsC

var percentDamage = (float)(_damageable.GetTotalDamage(ent.AsNullable()) / amount);

// start-backmen: consciousness
if (TryComp<ConsciousnessComponent>(ent, out var consciousness))
{
percentDamage = (float)(consciousness.Threshold / (consciousness.Cap - consciousness.Consciousness)); // backmen edit; consciousness
var span = consciousness.Cap - consciousness.Threshold;
if (span != 0)
percentDamage = Math.Clamp((float)((consciousness.Cap - consciousness.Consciousness) / span), 0f, 1f);
}
// end-backmen: consciousness
Comment thread
coderabbitai[bot] marked this conversation as resolved.
//basically make it scale from 1 to the multiplier.

var output = percentDamage * (mod - 1) + 1;
Expand Down
2 changes: 2 additions & 0 deletions Content.Shared/StatusEffectNew/StatusEffectSystem.Relay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Content.Shared.Damage.Systems;
using Content.Shared.Eye.Blinding.Systems;
using Content.Shared.Flash;
using Content.Shared.HealthExaminable; // backmen: pain-immune-status
using Content.Shared.Mobs;
using Content.Shared.Mobs.Events;
using Content.Shared.Movement.Events;
Expand Down Expand Up @@ -56,6 +57,7 @@ private void InitializeRelay()

SubscribeLocalEvent<StatusEffectContainerComponent, CanVisionAttemptEvent>(RelayStatusEffectEvent); // backmen
SubscribeLocalEvent<StatusEffectContainerComponent, MobStateChangedEvent>(RefRelayStatusEffectEvent); // backmen
SubscribeLocalEvent<StatusEffectContainerComponent, HealthBeingExaminedEvent>(RelayStatusEffectEvent); // backmen: pain-immune-status
}

private void RefRelayStatusEffectEvent<T>(EntityUid uid, StatusEffectContainerComponent component, ref T args)
Expand Down
22 changes: 22 additions & 0 deletions Resources/Changelog/ChangelogBkm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3969,6 +3969,28 @@ Entries:
type: Add
id: 269
time: '2026-08-18T06:11:00.0000000+00:00'
- author: Zack Backmen
changes:
- message: "Исправлено: самолечение наборами (мазь, набор для ушибов и т.п.) больше не занимает десятки секунд на лёгких ранах."
type: Fix
- message: "Время самолечения наборами снова стандартное: около 3 секунд в нормальном состоянии и дольше, если вы тяжело ранены."
type: Tweak
id: 270
time: '2026-08-19T11:10:00.0000000+00:00'
- author: Zack Backmen
changes:
- message: "Химики больше не варят морфин из уксуса и спирта: нужен опий из мака или очень дорогой заказ в карго."
type: Tweak
- message: "В обычном маке опий в малых количествах; мутация даёт сиреневый опийный мак с большим выходом."
type: Add
- message: "Новый боевой анальгетик морфазин из морфина и дорогого карго-реагента: полностью глушит боль, но даёт транс (тряска, заикание, галлюцинации) и после отключает."
type: Add
- message: "Боевые медипены содержат морфазин; в оружейных сейфах СБ лежат боевые медипены."
type: Add
- message: "Пока действует иммунитет к боли, в алертах показывается иконка."
type: Add
id: 271
time: '2026-08-19T11:15:00.0000000+00:00'

Name: ChangelogBkm
Order: 0
Expand Down
2 changes: 2 additions & 0 deletions Resources/Locale/en-US/backmen/alerts/pain.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alerts-pain-immune-name = Pain immunity
alerts-pain-immune-desc = You don't feel pain.
6 changes: 6 additions & 0 deletions Resources/Locale/en-US/backmen/reagents/narcotics.ftl
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
reagent-name-morphine = morphine
reagent-desc-morphine = A painkiller that allows the patient to move at full speed even when injured. Causes drowsiness and eventually unconsciousness in high doses. Overdose will cause a variety of effects, ranging from minor to lethal.
reagent-name-opium = opium
reagent-desc-opium = A dark oily poppy extract. Mildly dulls pain; high doses cause drowsiness. Must be refined into morphine for real pain relief.
reagent-name-nociceptine = nociceptine
reagent-desc-nociceptine = A military concentrate used to synthesize morphazine. Useless on its own and mildly poisonous.
reagent-name-morphazine = morphazine
reagent-desc-morphazine = A combat analgesic that fully blocks pain, but causes a trance of jittering, stuttering, and hallucinations. When it wears off the user blacks out. Overdose strains the heart and breathing.
reagent-name-stimulants-super = суперстимулятор
reagent-desc-stimulants-super = Сверхмощный коктейль самых сильных веществ галактики, быстро лечащий от механического урона и ожогов, значительно повыщающий скорость и выносливость, однако имеющий выраженные побочные эффекты. При передозировке вызывает сильное отравление.
4 changes: 4 additions & 0 deletions Resources/Locale/en-US/seeds/seeds.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ seeds-chilly-name = chilly
seeds-chilly-display-name = chilly peppers
seeds-poppy-name = poppy
seeds-poppy-display-name = poppies
# start-backmen: poppy-opium
seeds-opium-poppy-name = opium poppy
seeds-opium-poppy-display-name = opium poppies
# end-backmen: poppy-opium
seeds-aloe-name = aloe
seeds-aloe-display-name = aloe
seeds-laughin-pea-name = laughin' peas
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ent-StatusEffectPainImmune = pain immunity
.desc = { ent-MobStatusEffectBase.desc }
2 changes: 2 additions & 0 deletions Resources/Locale/ru-RU/backmen/alerts/pain.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alerts-pain-immune-name = Иммунитет к боли
alerts-pain-immune-desc = Вы не чувствуете боли.
6 changes: 6 additions & 0 deletions Resources/Locale/ru-RU/backmen/reagents/narcotics.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ reagent-name-stimulants-super = суперстимулятор
reagent-desc-stimulants-super = Сверхмощный коктейль самых сильных веществ галактики, быстро лечащий от механического урона и ожогов, значительно повыщающий скорость и выносливость, однако имеющий выраженные побочные эффекты. При передозировке вызывает сильное отравление.
reagent-name-morphine = морфин
reagent-desc-morphine = Обезболивающее средство, позволяющее пациенту двигаться с полной скоростью даже при травме. В больших дозах вызывает сонливость и в конечном итоге потерю сознания. Передозировка может привести к различным последствиям, от незначительных до смертельных.
reagent-name-opium = опий
reagent-desc-opium = Тёмный маслянистый экстракт мака. Слабо глушит боль; в больших дозах вызывает сонливость. Для настоящего обезболивания его нужно очистить в морфин.
reagent-name-nociceptine = ноцицептин
reagent-desc-nociceptine = Военный концентрат для синтеза морфазина. Сам по себе почти бесполезен и слегка ядовит.
reagent-name-morphazine = морфазин
reagent-desc-morphazine = Боевой анальгетик: полностью глушит боль, но вызывает транс — тряску, заикание и галлюцинации. Когда препарат выветривается, боец отключается. Передозировка бьёт по сердцу и дыханию.
4 changes: 4 additions & 0 deletions Resources/Locale/ru-RU/seeds/seeds.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ seeds-chilly-name = перец чилли
seeds-chilly-display-name = перец чилли
seeds-poppy-name = мак
seeds-poppy-display-name = мак
# start-backmen: poppy-opium
seeds-opium-poppy-name = опийный мак
seeds-opium-poppy-display-name = опийный мак
# end-backmen: poppy-opium
seeds-aloe-name = алоэ
seeds-aloe-display-name = алоэ
seeds-laughin-pea-name = смешной горошек
Expand Down
Loading
Loading