diff --git a/Content.IntegrationTests/Tests/Body/DetachableOrganSystemTest.cs b/Content.IntegrationTests/Tests/Body/DetachableOrganSystemTest.cs new file mode 100644 index 0000000000..67d210ccf8 --- /dev/null +++ b/Content.IntegrationTests/Tests/Body/DetachableOrganSystemTest.cs @@ -0,0 +1,111 @@ +using System.Linq; +using Content.IntegrationTests.Fixtures; +using Content.IntegrationTests.Fixtures.Attributes; +using Content.Shared.Body; +using Robust.Shared.Containers; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; + +namespace Content.IntegrationTests.Tests.Body; + +[TestFixture] +[TestOf(typeof(DetachableOrganSystem))] +public sealed class DetachableOrganSystemTest : GameTest +{ + private const string DetachTestOldBody = "DetachTestOldBody"; + private const string DetachTestNewBody = "DetachTestNewBody"; + private const string DetachTestGrandParentOrgan = "DetachTestGrandParentOrgan"; + private const string DetachTestRootOrgan = "DetachTestRootOrgan"; + private const string DetachTestChildOrgan = "DetachTestChildOrgan"; + private const string DetachTestSiblingOrgan = "DetachTestSiblingOrgan"; + + [TestPrototypes] + private const string Prototypes = $@" +- type: entity + id: {DetachTestOldBody} + components: + - type: Body + +- type: entity + id: {DetachTestNewBody} + components: + - type: Body + +- type: entity + id: {DetachTestGrandParentOrgan} + components: + - type: Organ + - type: ParentOrgan + - type: ChildOrgan + +- type: entity + id: {DetachTestRootOrgan} + components: + - type: Organ + - type: ParentOrgan + - type: ChildOrgan + - type: DetachableOrgan + detachedBody: DetachTestNewBody + +- type: entity + id: {DetachTestChildOrgan} + components: + - type: Organ + - type: ChildOrgan + +- type: entity + id: {DetachTestSiblingOrgan} + components: + - type: Organ + - type: ChildOrgan +"; + + [SidedDependency(Side.Server)] private SharedContainerSystem _container = default!; + [SidedDependency(Side.Server)] private OrganRelationSystem _organRelation = default!; + [SidedDependency(Side.Server)] private DetachableOrganSystem _detachableOrgan = default!; + + [Test] + [RunOnSide(Side.Server)] + public void Detach() + { + var oldBody = SSpawn(DetachTestOldBody); + var oldBodyContainer = _container.GetContainer(oldBody, BodyComponent.ContainerID); + + var grandParent = SSpawn(DetachTestGrandParentOrgan); + var root = SSpawn(DetachTestRootOrgan); + var child = SSpawn(DetachTestChildOrgan); + var sibling = SSpawn(DetachTestSiblingOrgan); + + _container.Insert(grandParent, oldBodyContainer, force: true); + _container.Insert(root, oldBodyContainer, force: true); + _container.Insert(child, oldBodyContainer, force: true); + _container.Insert(sibling, oldBodyContainer, force: true); + + _organRelation.Relate(grandParent, root); + _organRelation.Relate(grandParent, sibling); + _organRelation.Relate(root, child); + + var newBody = _detachableOrgan.Detach(root); + + Assert.That(newBody, Is.Not.Null); + + var newBodyContained = _container.GetContainer(newBody!.Value, BodyComponent.ContainerID).ContainedEntities.ToList(); + Assert.That(newBodyContained, Is.EquivalentTo(new[] { root, child })); + + var oldBodyContained = oldBodyContainer.ContainedEntities.ToList(); + Assert.That(oldBodyContained, Is.EquivalentTo(new[] { grandParent, sibling })); + + var grandParentComp = SComp(grandParent); + Assert.That(grandParentComp.Children, Does.Not.Contain(root)); + Assert.That(grandParentComp.Children, Does.Contain(sibling)); + + Assert.That(SComp(root).Parent, Is.Null); + Assert.That(SComp(root).Children, Does.Contain(child)); + Assert.That(SComp(child).Parent, Is.EqualTo(root)); + + Assert.That(SComp(root).Body, Is.EqualTo(newBody)); + Assert.That(SComp(child).Body, Is.EqualTo(newBody)); + Assert.That(SComp(grandParent).Body, Is.EqualTo(oldBody)); + Assert.That(SComp(sibling).Body, Is.EqualTo(oldBody)); + } +} diff --git a/Content.IntegrationTests/Tests/Body/InitialBodySpawnTest.cs b/Content.IntegrationTests/Tests/Body/InitialBodySpawnTest.cs new file mode 100644 index 0000000000..8b1b855125 --- /dev/null +++ b/Content.IntegrationTests/Tests/Body/InitialBodySpawnTest.cs @@ -0,0 +1,67 @@ +using System.Linq; +using Content.IntegrationTests.Fixtures; +using Content.IntegrationTests.Fixtures.Attributes; +using Content.Shared.Body; +using Robust.Shared.Containers; +using Robust.Shared.GameObjects; + +namespace Content.IntegrationTests.Tests.Body; + +[TestFixture] +[TestOf(typeof(InitialBodySystem))] +public sealed class InitialBodySpawnTest : GameTest +{ + [TestPrototypes] + private const string Prototypes = @" +- type: organCategory + id: InitialBodySpawnTestParent + +- type: organCategory + id: InitialBodySpawnTestChild + +- type: entity + id: InitialBodySpawnTestBody + components: + - type: Body + - type: InitialBody + organs: + InitialBodySpawnTestParent: InitialBodySpawnTestParentOrgan + InitialBodySpawnTestChild: InitialBodySpawnTestChildOrgan + relationships: + InitialBodySpawnTestParent: [InitialBodySpawnTestChild] + +- type: entity + id: InitialBodySpawnTestParentOrgan + components: + - type: Organ + - type: ParentOrgan + +- type: entity + id: InitialBodySpawnTestChildOrgan + components: + - type: Organ + - type: ChildOrgan +"; + + [SidedDependency(Side.Server)] private SharedContainerSystem _container = default!; + + [Test] + [RunOnSide(Side.Server)] + public void SpawningWiresUpOrganRelations() + { + var body = SSpawn("InitialBodySpawnTestBody"); + + var bodyContainer = _container.GetContainer(body, BodyComponent.ContainerID); + var contained = bodyContainer.ContainedEntities.ToList(); + Assert.That(contained, Has.Count.EqualTo(2)); + + var parent = contained.Single(SEntMan.HasComponent); + var child = contained.Single(SEntMan.HasComponent); + + var parentComp = SComp(parent); + var childComp = SComp(child); + + Assert.That(parentComp.Children, Does.Contain(child)); + Assert.That(childComp.Parent, Is.EqualTo(parent)); + } +} diff --git a/Content.IntegrationTests/Tests/Body/InitialBodyValidationTest.cs b/Content.IntegrationTests/Tests/Body/InitialBodyValidationTest.cs new file mode 100644 index 0000000000..851964e422 --- /dev/null +++ b/Content.IntegrationTests/Tests/Body/InitialBodyValidationTest.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.Linq; +using Content.IntegrationTests.Fixtures; +using Content.IntegrationTests.Fixtures.Attributes; +using Content.Shared.Body; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; + +namespace Content.IntegrationTests.Tests.Body; + +[TestFixture] +[TestOf(typeof(InitialBodySystem))] +public sealed class InitialBodyValidationTest : GameTest +{ + [SidedDependency(Side.Server)] private IComponentFactory _componentFactory = default!; + [SidedDependency(Side.Server)] private IPrototypeManager _prototype = default!; + + [Test] + [RunOnSide(Side.Server)] + public void RelatedOrgansCanBeRelated() + { + using var scope = Assert.EnterMultipleScope(); + + foreach (var proto in _prototype.EnumeratePrototypes()) + { + if (proto.Abstract || Pair.IsTestEntityPrototype(proto.ID)) + continue; + + if (!proto.TryComp(out var initial, _componentFactory)) + continue; + + if (initial.Relationships is not { } relationships) + continue; + + var organs = initial.Organs; + foreach (var (parent, children) in relationships) + { + if (!organs.TryGetValue(parent, out var parentProtoId)) + continue; + + var parentProto = _prototype.Index(parentProtoId); + if (!parentProto.HasComp(_componentFactory)) + { + Assert.Fail($"{proto.ID}'s parent organ {parent} {parentProtoId} is missing {nameof(ParentOrganComponent)}"); + } + + foreach (var child in children) + { + if (!organs.TryGetValue(child, out var childProtoId)) + continue; + + var childProto = _prototype.Index(childProtoId); + if (!childProto.HasComp(_componentFactory)) + { + Assert.Fail($"{proto.ID}'s child organ {child} {childProtoId} (child of {parent} {parentProtoId}) is missing {nameof(ChildOrganComponent)}"); + } + } + } + } + } + + [Test] + [RunOnSide(Side.Server)] + public void NoMultipleParents() + { + using var scope = Assert.EnterMultipleScope(); + + foreach (var proto in _prototype.EnumeratePrototypes()) + { + if (proto.Abstract || Pair.IsTestEntityPrototype(proto.ID)) + continue; + + if (!proto.TryComp(out var initial, _componentFactory)) + continue; + + if (initial.Relationships is not { } relationships) + continue; + + var parentOf = new Dictionary, ProtoId>(); + foreach (var (parent, children) in relationships) + { + foreach (var child in children) + { + if (parentOf.TryGetValue(child, out var existingParent)) + { + Assert.Fail($"{proto.ID}'s organ category {child} is claimed as a child by both {existingParent} and {parent}"); + } + + parentOf[child] = parent; + } + } + } + } + + [Test] + [RunOnSide(Side.Server)] + public void InternalChildOrgansAreNotDetachable() + { + using var scope = Assert.EnterMultipleScope(); + + foreach (var proto in _prototype.EnumeratePrototypes()) + { + if (proto.Abstract || Pair.IsTestEntityPrototype(proto.ID)) + continue; + + if (!proto.HasComp(_componentFactory)) + continue; + + if (proto.HasComp(_componentFactory)) + { + Assert.Fail($"{proto.ID} has both {nameof(InternalChildOrganComponent)} and {nameof(DetachableOrganComponent)}. Pick a lane, make your organs internal or detachable, but not both."); + } + } + } + + [Test] + [RunOnSide(Side.Server)] + public void NoCyclicalRelationships() + { + using var scope = Assert.EnterMultipleScope(); + + foreach (var proto in _prototype.EnumeratePrototypes()) + { + if (proto.Abstract || Pair.IsTestEntityPrototype(proto.ID)) + continue; + + if (!proto.TryComp(out var initial, _componentFactory)) + continue; + + if (initial.Relationships is not { } relationships) + continue; + + var visited = new HashSet>(); + var stack = new List>(); + + foreach (var parent in relationships.Keys) + { + if (TryFindCycle(parent, relationships, visited, stack, out var cycle)) + { + Assert.Fail($"{proto.ID} has a cycle in its InitialBodyComponent relationships: {string.Join(" -> ", cycle)}"); + } + } + } + } + + private static bool TryFindCycle( + ProtoId node, + Dictionary, HashSet>> relationships, + HashSet> visited, + List> stack, + out List> cycle) + { + if (stack.Contains(node)) + { + var start = stack.IndexOf(node); + cycle = stack.Skip(start).Append(node).ToList(); + return true; + } + + cycle = new List>(); + + if (!visited.Add(node)) + return false; + + if (!relationships.TryGetValue(node, out var children)) + return false; + + stack.Add(node); + + foreach (var child in children) + { + if (TryFindCycle(child, relationships, visited, stack, out cycle)) + return true; + } + + stack.RemoveAt(stack.Count - 1); + return false; + } +} diff --git a/Content.IntegrationTests/Tests/Body/OrganRelationSystemTest.cs b/Content.IntegrationTests/Tests/Body/OrganRelationSystemTest.cs new file mode 100644 index 0000000000..ee012f9815 --- /dev/null +++ b/Content.IntegrationTests/Tests/Body/OrganRelationSystemTest.cs @@ -0,0 +1,93 @@ +using System.Linq; +using Content.IntegrationTests.Fixtures; +using Content.IntegrationTests.Fixtures.Attributes; +using Content.Shared.Body; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; + +namespace Content.IntegrationTests.Tests.Body; + +[TestFixture] +[TestOf(typeof(OrganRelationSystem))] +public sealed class OrganRelationSystemTest : GameTest +{ + [TestPrototypes] + private const string Prototypes = @" +- type: entity + id: OrganRelationTestOrgan + components: + - type: Organ + - type: ParentOrgan + - type: ChildOrgan +"; + + [SidedDependency(Side.Server)] private OrganRelationSystem _organRelation = default!; + + [Test] + [RunOnSide(Side.Server)] + public void RelateAndOrphan() + { + var parent = SSpawn("OrganRelationTestOrgan"); + var child = SSpawn("OrganRelationTestOrgan"); + + _organRelation.Relate(parent, child); + + var parentComp = SComp(parent); + var childComp = SComp(child); + + Assert.That(parentComp.Children, Does.Contain(child)); + Assert.That(childComp.Parent, Is.EqualTo(parent)); + + _organRelation.Orphan(child); + + Assert.That(parentComp.Children, Does.Not.Contain(child)); + Assert.That(childComp.Parent, Is.Null); + } + + [Test] + [RunOnSide(Side.Server)] + public void Traversal() + { + var grandParent = SSpawn("OrganRelationTestOrgan"); + var parent = SSpawn("OrganRelationTestOrgan"); + var child = SSpawn("OrganRelationTestOrgan"); + + _organRelation.Relate(grandParent, parent); + _organRelation.Relate(parent, child); + + var allChildren = _organRelation.AllChildren(grandParent).Select(e => e.Owner).ToList(); + Assert.That(allChildren, Is.EquivalentTo(new[] { parent, child })); + + var allParents = _organRelation.AllParents(child).Select(e => e.Owner).ToList(); + Assert.That(allParents, Is.EquivalentTo(new[] { parent, grandParent })); + } + + [Test] + [RunOnSide(Side.Server)] + public void ParentDeletion() + { + var parent = SSpawn("OrganRelationTestOrgan"); + var child = SSpawn("OrganRelationTestOrgan"); + + _organRelation.Relate(parent, child); + + SDeleteNow(parent); + + Assert.That(SEntMan.Deleted(child), Is.False); + Assert.That(SComp(child).Parent, Is.Null); + } + + [Test] + [RunOnSide(Side.Server)] + public void ChildDeletion() + { + var parent = SSpawn("OrganRelationTestOrgan"); + var child = SSpawn("OrganRelationTestOrgan"); + + _organRelation.Relate(parent, child); + + SDeleteNow(child); + + Assert.That(SComp(parent).Children, Does.Not.Contain(child)); + } +} diff --git a/Content.IntegrationTests/Tests/Sprite/ItemSpriteTest.cs b/Content.IntegrationTests/Tests/Sprite/ItemSpriteTest.cs index ca667f2e7a..4c2f0be60e 100644 --- a/Content.IntegrationTests/Tests/Sprite/ItemSpriteTest.cs +++ b/Content.IntegrationTests/Tests/Sprite/ItemSpriteTest.cs @@ -29,7 +29,8 @@ public sealed class PrototypeSaveTest : GameTest { // The only prototypes that should get ignored are those that REQUIRE setup to get a sprite. At that point it is // the responsibility of the spawner to ensure that a valid sprite is set. - "VirtualItem" + "VirtualItem", + "DetachedBody" }; [Test] diff --git a/Content.Server/Body/BodyCommand.cs b/Content.Server/Body/BodyCommand.cs new file mode 100644 index 0000000000..6e9fe5b838 --- /dev/null +++ b/Content.Server/Body/BodyCommand.cs @@ -0,0 +1,42 @@ +using System.Linq; +using Content.Server.Administration; +using Content.Shared.Administration; +using Content.Shared.Body; +using Robust.Shared.Containers; +using Robust.Shared.Toolshed; + +namespace Content.Server.Body; + +[ToolshedCommand, AdminCommand(AdminFlags.Debug)] +public sealed class BodyCommand : ToolshedCommand +{ + private SharedContainerSystem? _container; + + [CommandImplementation("insert")] + public void Insert([PipedArgument] EntityUid body, EntityUid organ) + { + _container ??= GetSys(); + + if (!_container.TryGetContainer(body, BodyComponent.ContainerID, out var container)) + return; + + _container.Insert(organ, container, force: true); + } + + [CommandImplementation("organs")] + public IEnumerable Organs([PipedArgument] EntityUid body) + { + _container ??= GetSys(); + + if (!_container.TryGetContainer(body, BodyComponent.ContainerID, out var container)) + return Enumerable.Empty(); + + return container.ContainedEntities.ToList(); + } + + [CommandImplementation("organs")] + public IEnumerable Organs([PipedArgument] IEnumerable body) + { + return body.SelectMany(Organs); + } +} diff --git a/Content.Server/Body/OrganCommand.cs b/Content.Server/Body/OrganCommand.cs new file mode 100644 index 0000000000..e791783cf6 --- /dev/null +++ b/Content.Server/Body/OrganCommand.cs @@ -0,0 +1,112 @@ +using System.Linq; +using Content.Server.Administration; +using Content.Shared.Administration; +using Content.Shared.Body; +using Robust.Shared.Containers; +using Robust.Shared.Prototypes; +using Robust.Shared.Toolshed; + +namespace Content.Server.Body; + +[ToolshedCommand, AdminCommand(AdminFlags.Debug)] +public sealed class OrganCommand : ToolshedCommand +{ + private DetachableOrganSystem? _detachableOrgan; + private SharedContainerSystem? _container; + private OrganRelationSystem? _organRelation; + + [CommandImplementation("parent")] + public EntityUid? Parent([PipedArgument] EntityUid child) + { + if (!TryComp(child, out var childOrgan)) + return null; + + return childOrgan.Parent; + } + + [CommandImplementation("parent")] + public IEnumerable Parent([PipedArgument] IEnumerable children) + { + return children.Select(Parent); + } + + [CommandImplementation("children")] + public IEnumerable Children([PipedArgument] EntityUid parent) + { + if (!TryComp(parent, out var parentOrgan)) + yield break; + + foreach (var child in parentOrgan.Children) + { + yield return child; + } + } + + [CommandImplementation("children")] + public IEnumerable Children([PipedArgument] IEnumerable parents) + { + return parents.SelectMany(Children); + } + + [CommandImplementation("detach")] + public EntityUid? Detach([PipedArgument] EntityUid organ) + { + _detachableOrgan ??= GetSys(); + return _detachableOrgan.Detach(organ); + } + + [CommandImplementation("detach")] + public IEnumerable Detach([PipedArgument] IEnumerable organs) + { + return organs.Select(Detach); + } + + [CommandImplementation("attach")] + public void Attach([PipedArgument] EntityUid parent, EntityUid child) + { + _container ??= GetSys(); + _organRelation ??= GetSys(); + + if (!TryComp(parent, out var parentOrgan) || + !TryComp(child, out var childOrgan) || + !TryComp(parent, out var parentOrganComponent) || + parentOrganComponent.Body is not { } body) + return; + + if (!_container.TryGetContainer(body, BodyComponent.ContainerID, out var container)) + return; + + _container.Insert(child, container, force: true); + _organRelation.Relate((parent, parentOrgan), (child, childOrgan)); + } + + [CommandImplementation("attach")] + public void Attach([PipedArgument] IEnumerable parents, EntityUid child) + { + foreach (var parent in parents) + { + Attach(parent, child); + } + } + + [CommandImplementation("is")] + public bool Is([PipedArgument] EntityUid organ, ProtoId category) + { + if (!TryComp(organ, out var organComp)) + return false; + + return organComp.Category == category; + } + + [CommandImplementation("is")] + public IEnumerable Is([PipedArgument] IEnumerable organs, ProtoId category) + { + return organs.Select(it => Is(it, category)); + } + + [CommandImplementation("of_type")] + public IEnumerable Typed([PipedArgument] IEnumerable organs, ProtoId category) + { + return organs.Where(it => Is(it, category)); + } +} diff --git a/Content.Shared/Body/ChildOrganComponent.cs b/Content.Shared/Body/ChildOrganComponent.cs new file mode 100644 index 0000000000..815f8d3423 --- /dev/null +++ b/Content.Shared/Body/ChildOrganComponent.cs @@ -0,0 +1,17 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Body; + +/// +/// An organ that can be a child to a parent organ within a body. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +[Access(typeof(OrganRelationSystem))] +public sealed partial class ChildOrganComponent : Component +{ + /// + /// The current organ that's a parent to this one, if any + /// + [DataField, AutoNetworkedField] + public EntityUid? Parent; +} diff --git a/Content.Shared/Body/DetachableOrganComponent.cs b/Content.Shared/Body/DetachableOrganComponent.cs new file mode 100644 index 0000000000..9cd0af4115 --- /dev/null +++ b/Content.Shared/Body/DetachableOrganComponent.cs @@ -0,0 +1,18 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Body; + +/// +/// An organ that can be detached with all of its children into a new body +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(DetachableOrganSystem))] +public sealed partial class DetachableOrganComponent : Component +{ + /// + /// The body to spawn upon detaching + /// + [DataField(required: true)] + public EntProtoId DetachedBody; +} diff --git a/Content.Shared/Body/DetachableOrganSystem.cs b/Content.Shared/Body/DetachableOrganSystem.cs new file mode 100644 index 0000000000..ce1d720acc --- /dev/null +++ b/Content.Shared/Body/DetachableOrganSystem.cs @@ -0,0 +1,52 @@ +using Content.Shared.Body; +using JetBrains.Annotations; +using Robust.Shared.Containers; +using Robust.Shared.Network; + +namespace Content.Shared.Body; + +public sealed partial class DetachableOrganSystem : EntitySystem +{ + [Dependency] private EntityQuery _detachableOrgan; + [Dependency] private EntityQuery _organ; + [Dependency] private OrganRelationSystem _organRelation = default!; + [Dependency] private SharedContainerSystem _container = default!; + + /// + /// Detaches an organ from its containing body. + /// + /// The organ to detach + /// The body that spawned when this organ was detached + [PublicAPI] + public EntityUid? Detach(Entity organ) + { + if (!_detachableOrgan.Resolve(organ, ref organ.Comp) || !_organ.TryComp(organ, out var organComp) || organComp.Body is not { } oldBody) + return null; + + _organRelation.Orphan(organ.Owner); + var body = PredictedSpawnNextToOrDrop(organ.Comp.DetachedBody, oldBody); + + if (!_container.TryGetContainer(body, BodyComponent.ContainerID, out var container)) + { + Log.Error($"Entity {ToPrettyString(body)} relied on by {nameof(DetachableOrganComponent)} on {ToPrettyString(organ)} is missing a container ({BodyComponent.ContainerID})."); + Del(body); + return null; + } + + if (!_container.Insert(organ.Owner, container, force: true)) + { + Log.Error($"{ToPrettyString(organ)} could not be transferred to new body {ToPrettyString(body)}."); + } + + foreach (var child in _organRelation.AllChildren(organ.Owner)) + { + if (!_container.Insert(child.Owner, container, force: true)) + { + Log.Error($"{ToPrettyString(child)} could not be transferred to new body {ToPrettyString(body)}."); + _organRelation.Orphan(child.AsNullable()); + } + } + + return body; + } +} diff --git a/Content.Shared/Body/InitialBodyComponent.cs b/Content.Shared/Body/InitialBodyComponent.cs index a27e5c092c..70326ea7d4 100644 --- a/Content.Shared/Body/InitialBodyComponent.cs +++ b/Content.Shared/Body/InitialBodyComponent.cs @@ -15,4 +15,10 @@ public sealed partial class InitialBodyComponent : Component /// [DataField(required: true)] public Dictionary, EntProtoId> Organs; + + /// + /// Organ relationships, based on their categories. Keys are parents to their values. + /// + [DataField] + public Dictionary, HashSet>>? Relationships; } diff --git a/Content.Shared/Body/InitialBodySystem.cs b/Content.Shared/Body/InitialBodySystem.cs index 6ac8066e54..5df762e495 100644 --- a/Content.Shared/Body/InitialBodySystem.cs +++ b/Content.Shared/Body/InitialBodySystem.cs @@ -1,12 +1,14 @@ using System.Numerics; using Robust.Shared.Containers; using Robust.Shared.Map; +using Robust.Shared.Prototypes; namespace Content.Shared.Body; public sealed partial class InitialBodySystem : EntitySystem { [Dependency] private SharedContainerSystem _container = default!; + [Dependency] private OrganRelationSystem _organRelation = default!; public override void Initialize() { @@ -31,8 +33,9 @@ public sealed partial class InitialBodySystem : EntitySystem var xform = Transform(ent); var coords = new EntityCoordinates(ent, Vector2.Zero); + var spawned = new Dictionary, EntityUid>(); - foreach (var proto in ent.Comp.Organs.Values) + foreach (var (part, proto) in ent.Comp.Organs) { // TODO: When e#6192 is merged replace this all with TrySpawnInContainer... var spawn = Spawn(proto, coords); @@ -41,6 +44,26 @@ public sealed partial class InitialBodySystem : EntitySystem { Log.Error($"Entity {ToPrettyString(ent)} with a {nameof(InitialBodyComponent)} failed to insert an entity: {ToPrettyString(spawn)}.\n"); Del(spawn); + continue; + } + + spawned[part] = spawn; + } + + if (ent.Comp.Relationships is null) + return; + + foreach (var (partId, parentUid) in spawned) + { + if (!ent.Comp.Relationships.TryGetValue(partId, out var children)) + continue; + + foreach (var childId in children) + { + if (!spawned.TryGetValue(childId, out var childUid)) + continue; + + _organRelation.Relate(parentUid, childUid); } } } diff --git a/Content.Shared/Body/InternalChildOrganComponent.cs b/Content.Shared/Body/InternalChildOrganComponent.cs new file mode 100644 index 0000000000..0f3c9348ef --- /dev/null +++ b/Content.Shared/Body/InternalChildOrganComponent.cs @@ -0,0 +1,9 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Body; + +/// +/// Marker components for child organs that are considered "internal" to their parent. e.g. kidneys are internal to a torso, but an arm isn't. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class InternalChildOrganComponent : Component; diff --git a/Content.Shared/Body/OrganRelationSystem.cs b/Content.Shared/Body/OrganRelationSystem.cs new file mode 100644 index 0000000000..d5236277e8 --- /dev/null +++ b/Content.Shared/Body/OrganRelationSystem.cs @@ -0,0 +1,120 @@ +using JetBrains.Annotations; +using Robust.Shared.Utility; + +namespace Content.Shared.Body; + +public sealed partial class OrganRelationSystem : EntitySystem +{ + [Dependency] private EntityQuery _child; + [Dependency] private EntityQuery _parent; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnParentShutdown); + SubscribeLocalEvent(OnChildShutdown); + } + + private void OnChildShutdown(Entity ent, ref ComponentShutdown args) + { + if (ent.Comp.Parent is not { } parentUid) + return; + + var parentComp = _parent.Comp(parentUid); + parentComp.Children.Remove(ent); + Dirty(parentUid, parentComp); + } + + private void OnParentShutdown(Entity ent, ref ComponentShutdown args) + { + if (ent.Comp.Children.Count == 0) + return; + + foreach (var childUid in ent.Comp.Children) + { + var childComp = _child.Comp(childUid); + childComp.Parent = null; + + Dirty(childUid, childComp); + } + } + + /// + /// Associates a parent organ and a child organ. + /// + [PublicAPI] + public void Relate(Entity parent, Entity child) + { + if (!_parent.Resolve(parent, ref parent.Comp) || !_child.Resolve(child, ref child.Comp)) + return; + + DebugTools.Assert(child.Comp.Parent == null); + + parent.Comp.Children.Add(child); + Dirty(parent, parent.Comp); + + child.Comp.Parent = parent; + Dirty(child, child.Comp); + } + + /// + /// Breaks the relationship between a child orphan and its parent. + /// + [PublicAPI] + public void Orphan(Entity child) + { + if (!_child.Resolve(child, ref child.Comp)) + return; + + if (child.Comp.Parent is not { } parentUid) + return; + + child.Comp.Parent = null; + Dirty(child, child.Comp); + + var parentComp = _parent.Comp(parentUid); + parentComp.Children.Remove(child); + Dirty(parentUid, parentComp); + } + + /// + /// Enumerates all parents of a child organ recursively. + /// + [PublicAPI] + public IEnumerable> AllParents(Entity child) + { + if (!_child.Resolve(child, ref child.Comp)) + yield break; + + while (child.Comp?.Parent is { } parent) + { + yield return (parent, _parent.Comp(parent)); + + if (!_child.TryGetComponent(parent, out var parentChild)) + yield break; + + child = (parent, parentChild); + } + } + + /// + /// Enumerates all children of a parent organ recursively. + /// + [PublicAPI] + public IEnumerable> AllChildren(Entity parent) + { + if (!_parent.Resolve(parent, ref parent.Comp, false)) + yield break; + + foreach (var child in parent.Comp.Children) + { + yield return (child, _child.Comp(child)); + + foreach (var childChild in AllChildren(child)) + { + yield return childChild; + } + } + } +} diff --git a/Content.Shared/Body/ParentOrganComponent.cs b/Content.Shared/Body/ParentOrganComponent.cs new file mode 100644 index 0000000000..e82fa70574 --- /dev/null +++ b/Content.Shared/Body/ParentOrganComponent.cs @@ -0,0 +1,17 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Body; + +/// +/// An organ that can be a parent to other organs. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +[Access(typeof(OrganRelationSystem))] +public sealed partial class ParentOrganComponent : Component +{ + /// + /// The current set of child organs parented to this one. + /// + [DataField, AutoNetworkedField] + public HashSet Children = new(); +} diff --git a/Resources/Locale/en-US/body/commands.ftl b/Resources/Locale/en-US/body/commands.ftl new file mode 100644 index 0000000000..e170e7e62a --- /dev/null +++ b/Resources/Locale/en-US/body/commands.ftl @@ -0,0 +1,23 @@ +command-description-body-insert = + Inserts the given organ into the body. + +command-description-body-organs = + Returns all organs contained within the body. + +command-description-organ-parent = + Returns the parent of the organ. + +command-description-organ-children = + Returns the children of the organ. + +command-description-organ-detach = + (DEBUG ONLY) Detaches an organ from its current body into a detached body. + +command-description-organ-attach = + Attaches an organ to another organ. + +command-description-organ-is = + Returns if an organ is the given type. + +command-description-organ-of_type = + Filters to organs of the given type. diff --git a/Resources/Prototypes/Body/base_organs.yml b/Resources/Prototypes/Body/base_organs.yml index f77e83e75a..0db5199615 100644 --- a/Resources/Prototypes/Body/base_organs.yml +++ b/Resources/Prototypes/Body/base_organs.yml @@ -43,7 +43,10 @@ - Meat - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseParent + - OrganBaseChildExternal id: OrganBaseTorso name: torso abstract: true @@ -68,7 +71,10 @@ - Special - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseParent + - OrganBaseChildExternal id: OrganBaseHead name: head abstract: true @@ -95,7 +101,10 @@ - HeadTop - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseParent + - OrganBaseChildExternal id: OrganBaseArmLeft name: left arm abstract: true @@ -114,7 +123,10 @@ - LArm - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseParent + - OrganBaseChildExternal id: OrganBaseArmRight name: right arm abstract: true @@ -133,7 +145,9 @@ - RArm - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildExternal id: OrganBaseHandLeft name: left hand abstract: true @@ -156,7 +170,9 @@ - LHand - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildExternal id: OrganBaseHandRight name: right hand abstract: true @@ -179,7 +195,10 @@ - RHand - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseParent + - OrganBaseChildExternal id: OrganBaseLegLeft name: left leg abstract: true @@ -198,7 +217,10 @@ - LLeg - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseParent + - OrganBaseChildExternal id: OrganBaseLegRight name: right leg abstract: true @@ -217,7 +239,9 @@ - RLeg - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildExternal id: OrganBaseFootLeft name: left foot abstract: true @@ -236,7 +260,9 @@ - LFoot - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildExternal id: OrganBaseFootRight name: right foot abstract: true @@ -255,7 +281,9 @@ - RFoot - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseBrain name: brain abstract: true @@ -288,7 +316,9 @@ Taco: Brain - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseEyes name: eyes abstract: true @@ -311,7 +341,9 @@ - Eyes - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseTongue name: tongue abstract: true @@ -324,7 +356,9 @@ - state: tongue - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseAppendix name: appendix abstract: true @@ -337,7 +371,9 @@ - state: appendix - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseEars name: ears abstract: true @@ -350,7 +386,9 @@ - state: ears - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseLungs name: lungs abstract: true @@ -383,7 +421,9 @@ canReact: false - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseHeart name: heart abstract: true @@ -399,7 +439,9 @@ stages: [ Bloodstream ] - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseStomach name: stomach abstract: true @@ -431,7 +473,9 @@ maxVol: 150 # 1.25 liters which is average for a human - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseLiver name: liver abstract: true @@ -449,7 +493,9 @@ stages: [ Metabolites ] - type: entity - parent: OrganBase + parent: + - OrganBase + - OrganBaseChildInternal id: OrganBaseKidneys name: kidneys abstract: true @@ -498,3 +544,26 @@ sexStateOverrides: Male: torso_m Female: torso_f + +- type: entity + abstract: true + id: OrganBaseParent + components: + - type: ParentOrgan + - type: DetachableOrgan + detachedBody: DetachedBody + +- type: entity + abstract: true + id: OrganBaseChildExternal + components: + - type: ChildOrgan + - type: DetachableOrgan + detachedBody: DetachedBody + +- type: entity + abstract: true + id: OrganBaseChildInternal + components: + - type: ChildOrgan + - type: InternalChildOrgan diff --git a/Resources/Prototypes/Body/detached.yml b/Resources/Prototypes/Body/detached.yml new file mode 100644 index 0000000000..65e370eb8c --- /dev/null +++ b/Resources/Prototypes/Body/detached.yml @@ -0,0 +1,15 @@ +- type: entity + parent: + - BaseSpeciesLayers + id: DetachedBody + save: false + components: + - type: Clickable + - type: Body + - type: VisualBody + - type: ContainerContainer + - type: Appearance + - type: HideableHumanoidLayers + - type: Item + - type: InteractionOutline + - type: InputMover diff --git a/Resources/Prototypes/Body/species_appearance.yml b/Resources/Prototypes/Body/species_appearance.yml index e70b6abb38..574fdbf820 100644 --- a/Resources/Prototypes/Body/species_appearance.yml +++ b/Resources/Prototypes/Body/species_appearance.yml @@ -87,3 +87,11 @@ type: HumanoidMarkingModifierBoundUserInterface enum.StrippingUiKey.Key: type: StrippableBoundUserInterface + - type: InitialBody + relationships: + Torso: [Head, ArmLeft, ArmRight, LegLeft, LegRight, Appendix, Lungs, Heart, Stomach, Liver, Kidneys] + Head: [Brain, Eyes, Tongue, Ears] + ArmLeft: [HandLeft] + ArmRight: [HandRight] + LegLeft: [FootLeft] + LegRight: [FootRight]