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