diff --git a/Content.Client/Backmen/Body/LooseOrganVisualSystem.cs b/Content.Client/Backmen/Body/LooseOrganVisualSystem.cs
deleted file mode 100644
index a797cefc314..00000000000
--- a/Content.Client/Backmen/Body/LooseOrganVisualSystem.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using System.Numerics;
-using Content.Shared.Body;
-using Content.Shared.Body.Organ;
-using Robust.Client.GameObjects;
-using Robust.Shared.Prototypes;
-
-namespace Content.Client.Backmen.Body;
-
-///
-/// Internal organs use tiny RSI states; once removed from a body they are hard to spot on the floor.
-///
-public sealed partial class LooseOrganVisualSystem : EntitySystem
-{
- private static readonly Vector2 SmallLooseScale = new(3f, 3f);
- private static readonly Vector2 InternalLooseScale = new(2f, 2f);
-
- private static readonly HashSet> SmallCategories =
- [
- "Eyes",
- "Ears",
- "Brain",
- "Tongue",
- "Appendix",
- ];
-
- private static readonly HashSet> InternalCategories =
- [
- "Lungs",
- "Heart",
- "Stomach",
- "Liver",
- "Kidneys",
- ];
-
- [Dependency] private SpriteSystem _sprite = default!;
-
- public override void Initialize()
- {
- base.Initialize();
-
- SubscribeLocalEvent(OnOrganStartup);
- SubscribeLocalEvent(OnOrganInserted);
- SubscribeLocalEvent(OnOrganRemoved);
- }
-
- private void OnOrganStartup(Entity ent, ref ComponentStartup args) =>
- UpdateLooseScale(ent);
-
- private void OnOrganInserted(Entity ent, ref OrganGotInsertedEvent args) =>
- UpdateLooseScale(ent);
-
- private void OnOrganRemoved(Entity ent, ref OrganGotRemovedEvent args) =>
- UpdateLooseScale(ent);
-
- private void UpdateLooseScale(Entity ent)
- {
- if (!TryComp(ent, out SpriteComponent? sprite))
- return;
-
- if (ent.Comp.Body != null)
- {
- _sprite.SetScale((ent, sprite), Vector2.One);
- return;
- }
-
- if (ent.Comp.Category is not { } category)
- return;
-
- var scale = SmallCategories.Contains(category)
- ? SmallLooseScale
- : InternalCategories.Contains(category)
- ? InternalLooseScale
- : Vector2.One;
-
- _sprite.SetScale((ent, sprite), scale);
- }
-}
diff --git a/Content.IntegrationTests/Tests/Backmen/Body/DetachedLegFootReattachTest.cs b/Content.IntegrationTests/Tests/Backmen/Body/DetachedLegFootReattachTest.cs
new file mode 100644
index 00000000000..982a532d59e
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Backmen/Body/DetachedLegFootReattachTest.cs
@@ -0,0 +1,67 @@
+using Content.IntegrationTests.Fixtures;
+using Content.Shared.Backmen.Body.OrganRelations;
+using Content.Shared.Backmen.Body.Systems;
+using Content.Shared.Body;
+using Content.Shared.Body.Organ;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
+
+namespace Content.IntegrationTests.Tests.Backmen.Body;
+
+///
+/// Reattaching a leg from a detached limb bundle must not delete the paired foot.
+///
+[TestFixture]
+public sealed class DetachedLegFootReattachTest : GameTest
+{
+ public override PoolSettings PoolSettings => new() { Connected = false, Dirty = true };
+
+ [Test]
+ public async Task ReattachingLegFromDetachedBundle_PreservesFoot()
+ {
+ var map = await Pair.CreateTestMap();
+ NetEntity netPatient = default;
+ NetEntity netFoot = default;
+
+ await Server.WaitAssertion(() =>
+ {
+ var bodySys = Server.EntMan.System();
+ var organRelations = Server.EntMan.System();
+
+ var patient = Server.EntMan.SpawnEntity("MobHuman", map.MapCoords);
+ var bundle = Server.EntMan.SpawnEntity("BackmenDetachedBody", map.MapCoords);
+ var leg = Server.EntMan.SpawnEntity("OrganHumanLegLeft", MapCoordinates.Nullspace);
+ var foot = Server.EntMan.SpawnEntity("OrganHumanFootLeft", MapCoordinates.Nullspace);
+
+ Assert.That(bodySys.InsertOrganIntoBody(bundle, leg), Is.True);
+ Assert.That(bodySys.InsertOrganIntoBody(bundle, foot), Is.True);
+
+ organRelations.WireRelationships((bundle, Server.EntMan.GetComponent(bundle)));
+
+ Assert.That(bodySys.InsertOrganIntoBody(patient, leg), Is.True,
+ "Leg should transfer from detached bundle to patient.");
+
+ Assert.That(Server.EntMan.EntityExists(foot) && !Server.EntMan.IsQueuedForDeletion(foot), Is.True,
+ "Foot must survive leg removal from detached bundle.");
+
+ Assert.That(bodySys.InsertOrganIntoBody(patient, foot), Is.True,
+ "Foot should transfer from detached bundle to patient.");
+
+ netPatient = Server.EntMan.GetNetEntity(patient);
+ netFoot = Server.EntMan.GetNetEntity(foot);
+ });
+
+ await Server.WaitIdleAsync();
+
+ await Server.WaitAssertion(() =>
+ {
+ var patient = Server.EntMan.GetEntity(netPatient);
+ var foot = Server.EntMan.GetEntity(netFoot);
+ var bodySys = Server.EntMan.System();
+
+ Assert.That(Server.EntMan.EntityExists(foot), Is.True);
+ Assert.That(bodySys.TryGetOrganByCategory(patient, "FootLeft", out _), Is.True);
+ });
+ }
+}
diff --git a/Content.IntegrationTests/Tests/Backmen/Body/GibDetachedBodyTest.cs b/Content.IntegrationTests/Tests/Backmen/Body/GibDetachedBodyTest.cs
new file mode 100644
index 00000000000..bcc1c8c4ed8
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Backmen/Body/GibDetachedBodyTest.cs
@@ -0,0 +1,48 @@
+using Content.IntegrationTests.Fixtures;
+using Content.Server.Backmen.Body.Systems;
+using Content.Shared.Backmen.Body.OrganRelations;
+using Content.Shared.Backmen.Body.Systems;
+using Content.Shared.Body;
+using Robust.Shared.GameObjects;
+
+namespace Content.IntegrationTests.Tests.Backmen.Body;
+
+///
+/// Full-body gib must scatter external parts into separate bundles.
+///
+[TestFixture]
+public sealed class GibDetachedBodyTest : GameTest
+{
+ public override PoolSettings PoolSettings => new() { Connected = false, Dirty = true };
+
+ [Test]
+ public async Task GibBody_CreatesMultipleDetachedBundles()
+ {
+ var map = await Pair.CreateTestMap();
+
+ await Server.WaitAssertion(() =>
+ {
+ var patient = Server.EntMan.SpawnEntity("MobHuman", map.MapCoords);
+ var bodySys = Server.EntMan.System();
+ bodySys.GibBody(patient, gibOrgans: true);
+ });
+
+ await Server.WaitIdleAsync();
+
+ await Server.WaitAssertion(() =>
+ {
+ var bundleCount = 0;
+ var enumerator = Server.EntMan.EntityQueryEnumerator();
+ while (enumerator.MoveNext(out var bundle))
+ {
+ bundleCount++;
+ Assert.That(Server.EntMan.TryGetComponent(bundle.Owner, out BodyComponent? body) && body!.Organs?.Count > 0,
+ Is.True,
+ "Each detached bundle should still contain at least one organ.");
+ }
+
+ Assert.That(bundleCount, Is.GreaterThan(3),
+ "Human gib should produce a detached bundle per external part, not a single pile.");
+ });
+ }
+}
diff --git a/Content.IntegrationTests/Tests/Backmen/Body/GraftArachneRejuvenateTest.cs b/Content.IntegrationTests/Tests/Backmen/Body/GraftArachneRejuvenateTest.cs
new file mode 100644
index 00000000000..96a416d1149
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Backmen/Body/GraftArachneRejuvenateTest.cs
@@ -0,0 +1,226 @@
+using System.Linq;
+using Content.IntegrationTests.Fixtures;
+using Content.Shared.Administration.Systems;
+using Content.Shared.Backmen.Body.OrganRelations;
+using Content.Shared.Backmen.Body.Systems;
+using Content.Shared.Backmen.Targeting;
+using Content.Shared.Body;
+using Content.Shared.Standing;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
+
+namespace Content.IntegrationTests.Tests.Backmen.Body;
+
+///
+/// graftarachne → rejuvenate → graftarachne must keep the arachne graft intact.
+/// Rejuvenation should not respawn human legs/feet on grafted bodies.
+///
+[TestFixture]
+public sealed class GraftArachneRejuvenateTest : GameTest
+{
+ public override PoolSettings PoolSettings => new() { Connected = false, Dirty = true };
+
+ ///
+ /// Mirrors graftarachne admin command behavior.
+ ///
+ private static void GraftArachne(IEntityManager entMan, EntityUid body)
+ {
+ var bodySys = entMan.System();
+ var organBody = entMan.System();
+ var organRelations = entMan.System();
+
+ if (!bodySys.BodySupportsArachneGraft(body)
+ || !entMan.TryGetComponent(body, out BodyComponent? bodyComp))
+ return;
+
+ foreach (var category in new ProtoId[] { "LegLeft", "LegRight" })
+ {
+ if (organBody.TryGetOrganByCategory((body, bodyComp), category, out var leg))
+ bodySys.RemoveOrgan(leg);
+ }
+
+ TryInsertGraft(entMan, bodySys, organBody, body, "BioSynthArachneFront", "ArachneFront");
+ TryInsertGraft(entMan, bodySys, organBody, body, "BioSynthArachneAbdomen", "ArachneAbdomen");
+ InsertSpiderLegs(entMan, bodySys, organBody, body, SurgeryBodyPartMapping.SpiderLegLeftSlots, "BioSynthSpiderLegLeft");
+ InsertSpiderLegs(entMan, bodySys, organBody, body, SurgeryBodyPartMapping.SpiderLegRightSlots, "BioSynthSpiderLegRight");
+
+ if (entMan.TryGetComponent(body, out bodyComp))
+ {
+ organRelations.WireGraftRelationships((body, bodyComp));
+ bodySys.SyncLegEntitiesForBody((body, bodyComp));
+ }
+ }
+
+ private static void TryInsertGraft(
+ IEntityManager entMan,
+ BkmBodySharedSystem bodySys,
+ BodySystem organBody,
+ EntityUid body,
+ EntProtoId graftId,
+ ProtoId category)
+ {
+ if (!entMan.TryGetComponent(body, out BodyComponent? bodyComp))
+ return;
+
+ if (organBody.TryGetOrganByCategory((body, bodyComp), category, out _))
+ return;
+
+ var graft = entMan.SpawnEntity(graftId, MapCoordinates.Nullspace);
+ bodySys.InsertOrganIntoBody(body, graft);
+ }
+
+ private static void InsertSpiderLegs(
+ IEntityManager entMan,
+ BkmBodySharedSystem bodySys,
+ BodySystem organBody,
+ EntityUid body,
+ ProtoId[] slots,
+ EntProtoId legGraftId)
+ {
+ if (!entMan.TryGetComponent(body, out BodyComponent? bodyComp))
+ return;
+
+ foreach (var slot in slots)
+ {
+ if (organBody.TryGetOrganByCategory((body, bodyComp), slot, out _))
+ continue;
+
+ var leg = entMan.SpawnEntity(legGraftId, MapCoordinates.Nullspace);
+ organBody.SetOrganCategory(leg, slot);
+ bodySys.InsertOrganIntoBody(body, leg);
+ }
+ }
+
+ private static void AssertGraftedArachneIntact(IEntityManager entMan, EntityUid body, string phase)
+ {
+ var bodySys = entMan.System();
+ var organBody = entMan.System();
+ var bodyComp = entMan.GetComponent(body);
+
+ Assert.That(bodySys.BodyHasArachneOrgan(body), Is.True, $"{phase}: body should have arachne graft organs.");
+
+ Assert.That(
+ organBody.TryGetOrganByCategory((body, bodyComp), "Torso", out _),
+ Is.True,
+ $"{phase}: torso must remain.");
+
+ Assert.That(
+ organBody.TryGetOrganByCategory((body, bodyComp), "ArachneAbdomen", out _),
+ Is.True,
+ $"{phase}: arachne abdomen must remain.");
+
+ Assert.That(
+ organBody.TryGetOrganByCategory((body, bodyComp), "ArachneFront", out _),
+ Is.True,
+ $"{phase}: arachne front must remain.");
+
+ foreach (var slot in SurgeryBodyPartMapping.SpiderLegLeftSlots
+ .Concat(SurgeryBodyPartMapping.SpiderLegRightSlots))
+ {
+ Assert.That(
+ organBody.TryGetOrganByCategory((body, bodyComp), slot, out _),
+ Is.True,
+ $"{phase}: missing spider leg slot {slot}.");
+ }
+
+ Assert.That(
+ organBody.TryGetOrganByCategory((body, bodyComp), "LegLeft", out _),
+ Is.False,
+ $"{phase}: human left leg should not be present on grafted arachne.");
+
+ Assert.That(
+ organBody.TryGetOrganByCategory((body, bodyComp), "LegRight", out _),
+ Is.False,
+ $"{phase}: human right leg should not be present on grafted arachne.");
+
+ Assert.That(
+ organBody.TryGetOrganByCategory((body, bodyComp), "FootLeft", out _),
+ Is.False,
+ $"{phase}: human left foot should not be present on grafted arachne.");
+
+ Assert.That(
+ organBody.TryGetOrganByCategory((body, bodyComp), "FootRight", out _),
+ Is.False,
+ $"{phase}: human right foot should not be present on grafted arachne.");
+
+ Assert.That(
+ bodyComp.LegEntities.Count,
+ Is.EqualTo(SurgeryBodyPartMapping.ArachneRequiredLegCount),
+ $"{phase}: spider legs must be tracked for movement.");
+
+ var standAttempt = new StandAttemptEvent();
+ entMan.EventBus.RaiseLocalEvent(body, standAttempt);
+ Assert.That(standAttempt.Cancelled, Is.False, $"{phase}: grafted arachne should be able to stand.");
+ }
+
+ [Test]
+ public async Task GraftArachne_Rejuvenate_GraftArachne_KeepsSpiderBody()
+ {
+ var map = await Pair.CreateTestMap();
+ NetEntity netMob = default;
+
+ await Server.WaitAssertion(() =>
+ {
+ var mob = Server.EntMan.SpawnEntity("MobHuman", map.MapCoords);
+ netMob = Server.EntMan.GetNetEntity(mob);
+
+ GraftArachne(Server.EntMan, mob);
+ AssertGraftedArachneIntact(Server.EntMan, mob, "After first graftarachne");
+ });
+
+ await Server.WaitIdleAsync();
+
+ await Server.WaitAssertion(() =>
+ {
+ var mob = Server.EntMan.GetEntity(netMob);
+ Server.EntMan.System().PerformRejuvenate(mob);
+ AssertGraftedArachneIntact(Server.EntMan, mob, "After rejuvenate");
+ });
+
+ await Server.WaitIdleAsync();
+
+ await Server.WaitAssertion(() =>
+ {
+ var mob = Server.EntMan.GetEntity(netMob);
+ GraftArachne(Server.EntMan, mob);
+ AssertGraftedArachneIntact(Server.EntMan, mob, "After second graftarachne");
+ });
+ }
+
+ [Test]
+ public async Task GraftArachne_RejectsFlatOrganNpc()
+ {
+ var map = await Pair.CreateTestMap();
+
+ await Server.WaitAssertion(() =>
+ {
+ var bodySys = Server.EntMan.System();
+ var organBody = Server.EntMan.System();
+
+ var human = Server.EntMan.SpawnEntity("MobHuman", map.MapCoords);
+ var npc = Server.EntMan.SpawnEntity("MobBaseNpc", map.MapCoords);
+
+ Assert.That(bodySys.BodySupportsArachneGraft(human), Is.True);
+ Assert.That(bodySys.BodySupportsArachneGraft(npc), Is.False);
+
+ Assert.That(Server.EntMan.TryGetComponent(npc, out BodyComponent? npcBody), Is.True);
+ var legCountBefore = npcBody!.LegEntities.Count;
+ Assert.That(
+ organBody.TryGetOrganByCategory((npc, npcBody), "Torso", out var torsoBefore),
+ Is.True);
+
+ GraftArachne(Server.EntMan, npc);
+
+ Assert.That(bodySys.BodyHasArachneOrgan(npc), Is.False);
+ Assert.That(npcBody.LegEntities.Count, Is.EqualTo(legCountBefore));
+ Assert.That(
+ organBody.TryGetOrganByCategory((npc, npcBody), "Torso", out var torsoAfter),
+ Is.True);
+ Assert.That(torsoAfter, Is.EqualTo(torsoBefore));
+ Assert.That(
+ organBody.TryGetOrganByCategory((npc, npcBody), "ArachneAbdomen", out _),
+ Is.False);
+ });
+ }
+}
diff --git a/Content.IntegrationTests/Tests/Backmen/Surgery/InternalOrganSurgeryTest.cs b/Content.IntegrationTests/Tests/Backmen/Surgery/InternalOrganSurgeryTest.cs
new file mode 100644
index 00000000000..b3b99c6dc35
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Backmen/Surgery/InternalOrganSurgeryTest.cs
@@ -0,0 +1,69 @@
+using Content.IntegrationTests.Fixtures;
+using Content.Shared.Backmen.Body.Systems;
+using Content.Shared.Backmen.Surgery.Body.Organs;
+using Content.Shared.Body;
+using Content.Shared.Body.Part;
+using Robust.Shared.GameObjects;
+
+namespace Content.IntegrationTests.Tests.Backmen.Surgery;
+
+///
+/// Internal organ surgeries (heart, lungs, etc.) must resolve only on the correct external host part.
+///
+[TestFixture]
+public sealed class InternalOrganSurgeryTest : GameTest
+{
+ public override PoolSettings PoolSettings => new() { Connected = false, Dirty = true };
+
+ [Test]
+ public async Task InternalOrgans_ScopedToHostPart()
+ {
+ var map = await Pair.CreateTestMap();
+
+ await Server.WaitAssertion(() =>
+ {
+ var patient = Server.EntMan.SpawnEntity("MobHuman", map.MapCoords);
+ var bodySys = Server.EntMan.System();
+
+ Assert.That(
+ bodySys.TryGetWoundableTargetByType(patient, BodyPartType.Chest, null, out var torso),
+ Is.True,
+ "Human should have a torso woundable target");
+
+ Assert.That(
+ bodySys.TryGetWoundableTargetByType(patient, BodyPartType.Head, null, out var head),
+ Is.True,
+ "Human should have a head woundable target");
+
+ Assert.That(
+ bodySys.TryGetInternalOrgansForHostPart(patient, torso, typeof(HeartComponent), out var heartOnTorso),
+ Is.True);
+ Assert.That(heartOnTorso, Has.Count.EqualTo(1));
+
+ Assert.That(
+ bodySys.TryGetInternalOrgansForHostPart(patient, torso, typeof(LungComponent), out var lungsOnTorso),
+ Is.True);
+ Assert.That(lungsOnTorso, Has.Count.EqualTo(1));
+
+ Assert.That(
+ bodySys.TryGetInternalOrgansForHostPart(patient, torso, typeof(LiverComponent), out var liverOnTorso),
+ Is.True);
+ Assert.That(liverOnTorso, Has.Count.EqualTo(1));
+
+ Assert.That(
+ bodySys.TryGetInternalOrgansForHostPart(patient, head, typeof(BrainComponent), out var brainOnHead),
+ Is.True);
+ Assert.That(brainOnHead, Has.Count.EqualTo(1));
+
+ Assert.That(
+ bodySys.TryGetInternalOrgansForHostPart(patient, head, typeof(HeartComponent), out _),
+ Is.False,
+ "Heart should not resolve when operating on the head");
+
+ Assert.That(
+ bodySys.TryGetInternalOrgansForHostPart(patient, torso, typeof(BrainComponent), out _),
+ Is.False,
+ "Brain should not resolve when operating on the torso");
+ });
+ }
+}
diff --git a/Content.Server/Backmen/Administration/Commands/Toolshed/GraftArachneCommand.cs b/Content.Server/Backmen/Administration/Commands/Toolshed/GraftArachneCommand.cs
index e6e0abca774..2d4a82d965e 100644
--- a/Content.Server/Backmen/Administration/Commands/Toolshed/GraftArachneCommand.cs
+++ b/Content.Server/Backmen/Administration/Commands/Toolshed/GraftArachneCommand.cs
@@ -37,6 +37,14 @@ public sealed class GraftArachneCommand : ToolshedCommand
return null;
}
+ _bodySys ??= GetSys();
+
+ if (!_bodySys.BodySupportsArachneGraft(input))
+ {
+ ctx.ReportError(new FlatOrgansError());
+ return null;
+ }
+
GraftArachne(input);
return input;
}
@@ -70,7 +78,10 @@ private void GraftArachne(EntityUid body)
InsertSpiderLegs(body, SurgeryBodyPartMapping.SpiderLegRightSlots, "BioSynthSpiderLegRight");
if (EntityManager.TryGetComponent(body, out bodyComp))
+ {
_organRelations.WireGraftRelationships((body, bodyComp));
+ _bodySys.SyncLegEntitiesForBody((body, bodyComp));
+ }
}
private void TryInsertGraft(
@@ -117,3 +128,13 @@ public FormattedMessage DescribeInner() =>
public Vector2i? IssueSpan { get; set; }
public StackTrace? Trace { get; set; }
}
+
+public record struct FlatOrgansError : IConError
+{
+ public FormattedMessage DescribeInner() =>
+ FormattedMessage.FromUnformatted(Loc.GetString("graftarachne-error-flat-organs"));
+
+ public string? Expression { get; set; }
+ public Vector2i? IssueSpan { get; set; }
+ public StackTrace? Trace { get; set; }
+}
diff --git a/Content.Server/Explosion/EntitySystems/ExplosionSystem.cs b/Content.Server/Explosion/EntitySystems/ExplosionSystem.cs
index c67fca2de80..0a518e5aab5 100644
--- a/Content.Server/Explosion/EntitySystems/ExplosionSystem.cs
+++ b/Content.Server/Explosion/EntitySystems/ExplosionSystem.cs
@@ -192,7 +192,10 @@ public override void TriggerExplosive(EntityUid uid, ExplosiveComponent? explosi
// start-backmen: surgery
var epicenter = uid;
if (_surgeryCavity.TryGetSurgeryCavityHost(uid, out var hostBody, out _))
+ {
epicenter = hostBody;
+ _body.GibBody(hostBody, gibOrgans: true, launchGibs: true, splatModifier: 2f);
+ }
QueueExplosion(epicenter,
explosive.ExplosionType,
diff --git a/Content.Shared/Backmen/Body/OrganRelations/DetachableOrganSystem.cs b/Content.Shared/Backmen/Body/OrganRelations/DetachableOrganSystem.cs
index c8649263ea0..0f1cfaa07a1 100644
--- a/Content.Shared/Backmen/Body/OrganRelations/DetachableOrganSystem.cs
+++ b/Content.Shared/Backmen/Body/OrganRelations/DetachableOrganSystem.cs
@@ -62,6 +62,9 @@ private void OnDetachableRemoved(Entity ent, ref Organ
foreach (var child in _organRelation.AllChildren(ent.Owner))
{
+ if (!TryComp(child.Owner, out var childOrgan) || childOrgan.Body != args.Target)
+ continue;
+
if (!_container.Insert(child.Owner, container, force: true))
{
Log.Error($"{ToPrettyString(child)} could not be transferred to new body {ToPrettyString(body)}.");
diff --git a/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.Body.cs b/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.Body.cs
index c25be42b633..b9fca5d2dfe 100644
--- a/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.Body.cs
+++ b/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.Body.cs
@@ -135,9 +135,12 @@ private void OnOrganRemovedFromBody(Entity ent, ref OrganRemovedF
&& !EntityManager.IsQueuedForDeletion(footOrgan))
{
// Surgery limb detachment moves the foot with the leg via DetachableOrganSystem.
+ // Detached limb bundles keep the foot until the leg is reattached (TransferDetachedSubtreeOrgans).
var detachableSurgery = HasComp(args.Organ) && HasComp(ent);
+ var detachedLimbBundle = HasComp(ent);
if (!detachableSurgery
+ && !detachedLimbBundle
&& Net.IsServer
&& ent.Comp.Organs != null
&& ent.Comp.Organs.Contains(footOrgan.Owner)
@@ -212,8 +215,13 @@ private void NubodyForceRestore(Entity ent, bool removeWounds)
if (TryComp(ent, out var initialBody))
{
+ var hasArachneGraft = BodyHasArachneOrgan(ent, ent.Comp);
+
foreach (var (category, proto) in initialBody.Organs)
{
+ if (hasArachneGraft && SurgeryBodyPartMapping.IsHumanLegOrFootCategory(category))
+ continue;
+
if (_nubody.TryGetOrganByCategory((ent, ent.Comp), category, out var existing)
&& !TerminatingOrDeleted(existing))
continue;
@@ -260,6 +268,8 @@ private void NubodyForceRestore(Entity ent, bool removeWounds)
}
_organRelations.WireRelationships((ent, ent.Comp!));
+ SyncLegEntities((ent, ent.Comp!));
+ UpdateMovementSpeed(ent, ent.Comp!);
}
private void OnRejuvenate(Entity ent, ref RejuvenateEvent args)
@@ -286,6 +296,11 @@ private void SyncLegEntities(Entity bodyEnt)
Dirty(bodyEnt, bodyEnt.Comp);
}
+ ///
+ /// Rebuilds tracked leg organs after grafting or rejuvenation.
+ ///
+ public void SyncLegEntitiesForBody(Entity bodyEnt) => SyncLegEntities(bodyEnt);
+
public IEnumerable<(EntityUid Id, OrganComponent Component)> GetBodyOrgans(
EntityUid? bodyId,
BodyComponent? body = null)
@@ -319,11 +334,45 @@ public virtual HashSet GibBody(
if (!Resolve(bodyId, ref body, logMissing: false))
return gibs;
- foreach (var partUid in GetWoundableTargets(bodyId, body))
+ var woundables = GetWoundableTargets(bodyId, body).ToList();
+ woundables.Sort((a, b) => GetOrganRelationDepth(b).CompareTo(GetOrganRelationDepth(a)));
+
+ foreach (var partUid in woundables)
{
+ if (TerminatingOrDeleted(partUid)
+ || !TryComp(partUid, out var organ)
+ || organ.Body != bodyId)
+ continue;
+
if (TryComp(partUid, out GibbableComponent? gibbable))
gibSoundOverride ??= gibbable.GibSound;
+ if (HasComp(partUid))
+ {
+ _gibbingSystem.TryGibEntityWithRef(
+ bodyId,
+ partUid,
+ GibType.Skip,
+ contents,
+ ref gibs,
+ playAudio: false,
+ launchGibs: false,
+ allowedContainers: allowedContainers,
+ excludedContainers: excludedContainers);
+
+ if (!RemoveOrgan(partUid, organ))
+ continue;
+
+ if (TryGetDetachedBodyBundle(partUid, out var bundle))
+ {
+ gibs.Add(bundle);
+ if (launchGibs)
+ FlingGib(bundle, splatDirection, splatModifier, splatCone);
+ }
+
+ continue;
+ }
+
_gibbingSystem.TryGibEntityWithRef(bodyId, partUid, gib, contents, ref gibs, playAudio: false,
launchGibs: true, launchDirection: splatDirection, launchImpulse: GibletLaunchImpulse * splatModifier,
launchImpulseVariance: GibletLaunchImpulseVariance, launchCone: splatCone,
@@ -358,6 +407,41 @@ public virtual HashSet GibBody(
return gibs;
}
+ private int GetOrganRelationDepth(EntityUid organId)
+ {
+ var depth = 0;
+ var current = organId;
+
+ while (TryComp(current, out var child) && child.Parent is { } parent)
+ {
+ depth++;
+ current = parent;
+ }
+
+ return depth;
+ }
+
+ private bool TryGetDetachedBodyBundle(EntityUid organId, out EntityUid bundle)
+ {
+ bundle = default;
+
+ if (!Containers.TryGetContainingContainer(organId, out var container)
+ || !HasComp(container.Owner))
+ return false;
+
+ bundle = container.Owner;
+ return true;
+ }
+
+ private void FlingGib(EntityUid target, Vector2? direction, float splatModifier, Angle scatterCone)
+ {
+ SharedTransform.AttachToGridOrMap(target);
+ var scatterAngle = direction?.ToAngle() ?? _random.NextAngle();
+ var scatterVector = _random.NextAngle(scatterAngle - scatterCone / 2, scatterAngle + scatterCone / 2)
+ .ToVec() * (GibletLaunchImpulse * splatModifier + _random.NextFloat(GibletLaunchImpulseVariance));
+ _physics.ApplyLinearImpulse(target, scatterVector);
+ }
+
public virtual HashSet GibPart(
EntityUid partId,
BodyPartComponent? part = null,
diff --git a/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.Woundables.cs b/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.Woundables.cs
index 38a6a5d2ea3..a21c58d37b0 100644
--- a/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.Woundables.cs
+++ b/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.Woundables.cs
@@ -139,6 +139,48 @@ public bool BodyHasArachneOrgan(EntityUid bodyId, BodyComponent? body = null)
return false;
}
+ ///
+ /// Arachne grafting requires layered humanoids,
+ /// not flat-sprite NPC organ sets.
+ ///
+ public bool BodySupportsArachneGraft(EntityUid bodyId) =>
+ HasComp(bodyId);
+
+ ///
+ /// Finds internal organs of a given type hosted under the external organ selected for surgery.
+ ///
+ public bool TryGetInternalOrgansForHostPart(
+ EntityUid bodyId,
+ EntityUid hostPart,
+ Type organComponentType,
+ [NotNullWhen(true)] out List<(EntityUid Id, OrganComponent Organ)>? organs)
+ {
+ organs = null;
+
+ if (!TryComp(hostPart, out var hostOrgan) || hostOrgan.Category is not { } hostCategory)
+ return false;
+
+ if (!TryGetBodyPartOrgans(bodyId, organComponentType, out var all) || all == null)
+ return false;
+
+ var filtered = new List<(EntityUid Id, OrganComponent Organ)>();
+ foreach (var organ in all)
+ {
+ if (organ.Organ.Category is not { } category
+ || !InternalOrganHostCategory.TryGetValue(category, out var expectedHost)
+ || expectedHost != hostCategory)
+ continue;
+
+ filtered.Add(organ);
+ }
+
+ if (filtered.Count == 0)
+ return false;
+
+ organs = filtered;
+ return true;
+ }
+
///
/// Count of inserted human foot organs (FootLeft / FootRight).
///
diff --git a/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.cs b/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.cs
index dee7c2deae8..ede4597f2f9 100644
--- a/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.cs
+++ b/Content.Shared/Backmen/Body/Systems/BkmBodySharedSystem.cs
@@ -8,6 +8,7 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
+using Robust.Shared.Physics.Systems;
namespace Content.Shared.Backmen.Body.Systems;
@@ -43,6 +44,7 @@ public abstract partial class BkmBodySharedSystem : EntitySystem
[Dependency] protected StandingStateSystem Standing = default!;
[Dependency] private BodySystem _nubody = default!;
[Dependency] protected DamageableSystem Damageable = default!;
+ [Dependency] private SharedPhysicsSystem _physics = default!;
public override void Initialize()
{
diff --git a/Content.Shared/Backmen/Surgery/SharedSurgerySystem.Steps.cs b/Content.Shared/Backmen/Surgery/SharedSurgerySystem.Steps.cs
index f022564d0ab..b1e0615580a 100644
--- a/Content.Shared/Backmen/Surgery/SharedSurgerySystem.Steps.cs
+++ b/Content.Shared/Backmen/Surgery/SharedSurgerySystem.Steps.cs
@@ -895,11 +895,11 @@ private void OnAddOrganCheck(Entity ent, ref Surge
|| organComp.Organ is null)
return;
- var lookup = args.Body;
+ var lookup = args.Part;
foreach (var reg in organComp.Organ.Values)
{
- if (!_body.TryGetBodyPartOrgans(lookup, reg.Component.GetType(), out var _))
+ if (!_body.TryGetInternalOrgansForHostPart(args.Body, lookup, reg.Component.GetType(), out var _))
args.Cancelled = true;
}
}
@@ -911,11 +911,11 @@ private void OnAffixOrganStep(Entity ent, ref Su
|| !removedOrganComp.Reattaching)
return;
- var lookup = args.Body;
+ var lookup = args.Part;
foreach (var reg in removedOrganComp.Organ.Values)
{
- _body.TryGetBodyPartOrgans(lookup, reg.Component.GetType(), out var organs);
+ _body.TryGetInternalOrgansForHostPart(args.Body, lookup, reg.Component.GetType(), out var organs);
if (organs != null && organs.Count > 0)
{
RemComp(organs[0].Id);
@@ -934,11 +934,11 @@ private void OnAffixOrganCheck(Entity ent, ref S
|| !removedOrganComp.Reattaching)
return;
- var lookup = args.Body;
+ var lookup = args.Part;
foreach (var reg in removedOrganComp.Organ.Values)
{
- _body.TryGetBodyPartOrgans(lookup, reg.Component.GetType(), out var organs);
+ _body.TryGetInternalOrgansForHostPart(args.Body, lookup, reg.Component.GetType(), out var organs);
if (organs != null
&& organs.Count > 0
&& organs.Any(organ => HasComp(organ.Id)))
@@ -952,11 +952,11 @@ private void OnRemoveOrganStep(Entity ent, ref
|| organComp.Organ == null)
return;
- var lookup = args.Body;
+ var lookup = args.Part;
foreach (var reg in organComp.Organ.Values)
{
- _body.TryGetBodyPartOrgans(lookup, reg.Component.GetType(), out var organs);
+ _body.TryGetInternalOrgansForHostPart(args.Body, lookup, reg.Component.GetType(), out var organs);
if (organs != null && organs.Count > 0)
{
_body.RemoveOrgan(organs[0].Id, organs[0].Organ);
@@ -971,11 +971,11 @@ private void OnRemoveOrganCheck(Entity ent, ref
|| organComp.Organ == null)
return;
- var lookup = args.Body;
+ var lookup = args.Part;
foreach (var reg in organComp.Organ.Values)
{
- if (_body.TryGetBodyPartOrgans(lookup, reg.Component.GetType(), out var organs)
+ if (_body.TryGetInternalOrgansForHostPart(args.Body, lookup, reg.Component.GetType(), out var organs)
&& organs != null
&& organs.Count > 0)
{
diff --git a/Content.Shared/Backmen/Surgery/SharedSurgerySystem.cs b/Content.Shared/Backmen/Surgery/SharedSurgerySystem.cs
index d0fa17575e5..535af5b288c 100644
--- a/Content.Shared/Backmen/Surgery/SharedSurgerySystem.cs
+++ b/Content.Shared/Backmen/Surgery/SharedSurgerySystem.cs
@@ -218,7 +218,7 @@ private void OnOrganConditionValid(Entity ent, r
foreach (var reg in ent.Comp.Organ.Values)
{
- if (_body.TryGetBodyPartOrgans(args.Body, reg.Component.GetType(), out var organs)
+ if (_body.TryGetInternalOrgansForHostPart(args.Body, args.Part, reg.Component.GetType(), out var organs)
&& organs.Count > 0)
{
if (ent.Comp.Inverse
diff --git a/Resources/Locale/en-US/backmen/toolshed.ftl b/Resources/Locale/en-US/backmen/toolshed.ftl
index af93402542e..6eb81b54cc5 100644
--- a/Resources/Locale/en-US/backmen/toolshed.ftl
+++ b/Resources/Locale/en-US/backmen/toolshed.ftl
@@ -7,6 +7,7 @@ command-description-makefakefingerprint = Add fake fingerprints
command-description-graftarachne = Grafts a full arachne body onto a humanoid for testing (removes human legs, adds cephalothorax, abdomen, and spider legs).
graftarachne-error-no-body = Entity has no BodyComponent.
+graftarachne-error-flat-organs = Entity uses flat NPC organs and cannot receive an arachne graft.
command-description-stationrecord-adduser = Add user to manifest
diff --git a/Resources/Locale/ru-RU/backmen/toolshed.ftl b/Resources/Locale/ru-RU/backmen/toolshed.ftl
index 000550ae3d2..00435be732e 100644
--- a/Resources/Locale/ru-RU/backmen/toolshed.ftl
+++ b/Resources/Locale/ru-RU/backmen/toolshed.ftl
@@ -7,6 +7,7 @@ command-description-makefakefingerprint = Добавить fake отпечатк
command-description-graftarachne = Пришивает полное паучье тело человеку для тестов (ампутация ног, головогрудь, брюшко, восемь лап).
graftarachne-error-no-body = У сущности нет BodyComponent.
+graftarachne-error-flat-organs = У сущности плоские органы NPC — арахне-графт недоступен.
command-description-stationrecord-adduser = Добавить пользователя в манифест
command-description-stationrecord-remuser = Удалить пользователя из манифеста
command-description-changefaction-addFaction = Добавить фракцию
diff --git a/Resources/Prototypes/Body/base_organs.yml b/Resources/Prototypes/Body/base_organs.yml
index edf80e0008a..305b91cfdb0 100644
--- a/Resources/Prototypes/Body/base_organs.yml
+++ b/Resources/Prototypes/Body/base_organs.yml
@@ -281,6 +281,7 @@
- type: GibbableOrgan
- type: Organ
category: Eyes
+ - type: Eyes
- type: Sprite
layers:
- state: eyeball-l
@@ -373,6 +374,7 @@
- type: GibbableOrgan
- type: Organ
category: Heart
+ - type: Heart
- type: Sprite
layers:
- state: heart-on
@@ -417,6 +419,7 @@
- type: GibbableOrgan
- type: Organ
category: Liver
+ - type: Liver
- type: Sprite
layers:
- state: liver