diff --git a/Content.Server/Gatherable/Components/GatherableComponent.cs b/Content.Server/Gatherable/Components/GatherableComponent.cs
deleted file mode 100644
index 8eb593fd13..0000000000
--- a/Content.Server/Gatherable/Components/GatherableComponent.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using Content.Shared.EntityTable.EntitySelectors;
-using Content.Shared.Whitelist;
-
-namespace Content.Server.Gatherable.Components;
-
-[RegisterComponent]
-[Access(typeof(GatherableSystem))]
-public sealed partial class GatherableComponent : Component
-{
- ///
- /// Whitelist for specifying the kind of tools can be used on a resource
- /// Supports multiple tags.
- ///
- [DataField(required: true)]
- public EntityWhitelist? ToolWhitelist;
-
- ///
- /// YAML example below
- /// (Tag1, Tag2, LootTableID1, LootTableID2 are placeholders for example)
- /// --------------------
- /// useMappedLoot: true
- /// toolWhitelist:
- /// tags:
- /// - Tag1
- /// - Tag2
- /// loot:
- /// Tag1: !type:NestedSelector
- /// tableId: LootTableID1
- /// Tag2: !type:NestedSelector
- /// tableId: LootTableID2
- ///
- [DataField]
- public Dictionary? Loot = new();
-
- ///
- /// Random shift of the appearing entity during gathering
- ///
- [DataField]
- public float GatherOffset = 0.3f;
-}
diff --git a/Content.Server/Gatherable/Components/SoundOnGatherComponent.cs b/Content.Server/Gatherable/Components/SoundOnGatherComponent.cs
deleted file mode 100644
index f46eb281f8..0000000000
--- a/Content.Server/Gatherable/Components/SoundOnGatherComponent.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using Content.Shared.Audio;
-using Robust.Shared.Audio;
-
-namespace Content.Server.Gatherable.Components;
-
-///
-/// Plays the specified sound when this entity is gathered.
-///
-[RegisterComponent, Access(typeof(GatherableSystem))]
-public sealed partial class SoundOnGatherComponent : Component
-{
- [ViewVariables(VVAccess.ReadWrite), DataField("sound")]
- public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/Effects/break_stone.ogg")
- {
- Params = AudioParams.Default
- .WithVariation(SharedContentAudioSystem.DefaultVariation)
- .WithVolume(-3f),
- };
-}
diff --git a/Content.Server/Gatherable/GatherableSystem.Projectile.cs b/Content.Server/Gatherable/GatherableSystem.Projectile.cs
deleted file mode 100644
index 3ab8872fd7..0000000000
--- a/Content.Server/Gatherable/GatherableSystem.Projectile.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using Content.Server.Gatherable.Components;
-using Content.Shared.Projectiles;
-using Robust.Shared.Physics.Events;
-
-namespace Content.Server.Gatherable;
-
-public sealed partial class GatherableSystem
-{
- private void InitializeProjectile()
- {
- SubscribeLocalEvent(OnProjectileCollide);
- }
-
- private void OnProjectileCollide(Entity gathering, ref StartCollideEvent args)
- {
- if (!args.OtherFixture.Hard ||
- args.OurFixtureId != SharedProjectileSystem.ProjectileFixture ||
- gathering.Comp.Amount <= 0 ||
- !TryComp(args.OtherEntity, out var gatherable))
- {
- return;
- }
-
- Gather(args.OtherEntity, gathering, gatherable);
- gathering.Comp.Amount--;
-
- if (gathering.Comp.Amount <= 0)
- QueueDel(gathering);
- }
-}
diff --git a/Content.Server/Gatherable/GatherableSystem.cs b/Content.Server/Gatherable/GatherableSystem.cs
deleted file mode 100644
index 3ecfd1d45a..0000000000
--- a/Content.Server/Gatherable/GatherableSystem.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using Content.Server.Destructible;
-using Content.Server.Gatherable.Components;
-using Content.Shared.EntityTable;
-using Content.Shared.Interaction;
-using Content.Shared.Tag;
-using Content.Shared.Weapons.Melee.Events;
-using Content.Shared.Whitelist;
-using Robust.Server.GameObjects;
-using Robust.Shared.Audio.Systems;
-using Robust.Shared.Random;
-
-namespace Content.Server.Gatherable;
-
-public sealed partial class GatherableSystem : EntitySystem
-{
- [Dependency] private IRobustRandom _random = default!;
- [Dependency] private DestructibleSystem _destructible = default!;
- [Dependency] private SharedAudioSystem _audio = default!;
- [Dependency] private TagSystem _tagSystem = default!;
- [Dependency] private TransformSystem _transform = default!;
- [Dependency] private EntityWhitelistSystem _whitelistSystem = default!;
- [Dependency] private EntityTableSystem _entityTable = default!;
-
- public override void Initialize()
- {
- base.Initialize();
-
- SubscribeLocalEvent(OnActivate);
- SubscribeLocalEvent(OnAttacked);
- InitializeProjectile();
- }
-
- private void OnAttacked(Entity gatherable, ref AttackedEvent args)
- {
- if (_whitelistSystem.IsWhitelistFailOrNull(gatherable.Comp.ToolWhitelist, args.Used))
- return;
-
- Gather(gatherable, args.User);
- }
-
- private void OnActivate(Entity gatherable, ref ActivateInWorldEvent args)
- {
- if (args.Handled || !args.Complex)
- return;
-
- if (_whitelistSystem.IsWhitelistFailOrNull(gatherable.Comp.ToolWhitelist, args.User))
- return;
-
- Gather(gatherable, args.User);
- args.Handled = true;
- }
-
- public void Gather(EntityUid gatheredUid, EntityUid? gatherer = null, GatherableComponent? component = null)
- {
- if (!Resolve(gatheredUid, ref component))
- return;
-
- if (TryComp(gatheredUid, out var soundComp))
- {
- _audio.PlayPvs(soundComp.Sound, Transform(gatheredUid).Coordinates);
- }
-
- // Complete the gathering process
- _destructible.DestroyEntity(gatheredUid);
-
- // Spawn the loot!
- if (component.Loot == null)
- return;
-
- var pos = _transform.GetMapCoordinates(gatheredUid);
-
- foreach (var (tag, table) in component.Loot)
- {
- if (tag != "All")
- {
- if (gatherer != null && !_tagSystem.HasTag(gatherer.Value, tag))
- continue;
- }
- var spawnLoot = _entityTable.GetSpawns(table);
- foreach (var loot in spawnLoot)
- {
- var spawnPos = pos.Offset(_random.NextVector2(component.GatherOffset));
- Spawn(loot, spawnPos);
- }
- }
- }
-}
diff --git a/Content.Shared/Gatherable/Components/GatherableComponent.cs b/Content.Shared/Gatherable/Components/GatherableComponent.cs
new file mode 100644
index 0000000000..de45dc27ee
--- /dev/null
+++ b/Content.Shared/Gatherable/Components/GatherableComponent.cs
@@ -0,0 +1,44 @@
+using Content.Shared.EntityTable.EntitySelectors;
+using Content.Shared.Whitelist;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Gatherable.Components;
+
+///
+/// Makes the entity possible to gather.
+///
+[RegisterComponent, NetworkedComponent]
+[Access(typeof(GatherableSystem))]
+public sealed partial class GatherableComponent : Component
+{
+ ///
+ /// Whitelist for specifying the kind of tools can be used on a resource.
+ /// Supports multiple tags.
+ ///
+ [DataField(required: true)]
+ public EntityWhitelist? ToolWhitelist;
+
+ ///
+ /// YAML example below
+ /// (Tag1, Tag2, LootTableID1, LootTableID2 are placeholders for example)
+ /// --------------------
+ /// useMappedLoot: true
+ /// toolWhitelist:
+ /// tags:
+ /// - Tag1
+ /// - Tag2
+ /// loot:
+ /// Tag1: !type:NestedSelector
+ /// tableId: LootTableID1
+ /// Tag2: !type:NestedSelector
+ /// tableId: LootTableID2
+ ///
+ [DataField]
+ public Dictionary? Loot = [];
+
+ ///
+ /// Random shift of the appearing entity during gathering.
+ ///
+ [DataField]
+ public float GatherOffset = 0.3f;
+}
diff --git a/Content.Server/Gatherable/Components/GatheringProjectileComponent.cs b/Content.Shared/Gatherable/Components/GatheringProjectileComponent.cs
similarity index 54%
rename from Content.Server/Gatherable/Components/GatheringProjectileComponent.cs
rename to Content.Shared/Gatherable/Components/GatheringProjectileComponent.cs
index 2ac8eedcd1..a908524ae0 100644
--- a/Content.Server/Gatherable/Components/GatheringProjectileComponent.cs
+++ b/Content.Shared/Gatherable/Components/GatheringProjectileComponent.cs
@@ -1,14 +1,17 @@
-namespace Content.Server.Gatherable.Components;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Gatherable.Components;
///
/// Destroys a gatherable entity when colliding with it.
///
-[RegisterComponent]
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(GatherableSystem))]
public sealed partial class GatheringProjectileComponent : Component
{
///
/// How many more times we can gather.
///
- [ViewVariables(VVAccess.ReadWrite), DataField("amount")]
+ [DataField, AutoNetworkedField]
public int Amount = 1;
}
diff --git a/Content.Shared/Gatherable/Components/SoundOnGatherComponent.cs b/Content.Shared/Gatherable/Components/SoundOnGatherComponent.cs
new file mode 100644
index 0000000000..9dc706ab18
--- /dev/null
+++ b/Content.Shared/Gatherable/Components/SoundOnGatherComponent.cs
@@ -0,0 +1,21 @@
+using Content.Shared.Audio;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Gatherable.Components;
+
+///
+/// Plays the specified sound when this entity is gathered.
+///
+[RegisterComponent, NetworkedComponent]
+[Access(typeof(GatherableSystem))]
+public sealed partial class SoundOnGatherComponent : Component
+{
+ ///
+ /// Sound to play when this entity is gathered.
+ ///
+ [DataField]
+ public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/Effects/break_stone.ogg", AudioParams.Default
+ .WithVariation(SharedContentAudioSystem.DefaultVariation)
+ .WithVolume(-3f));
+}
diff --git a/Content.Shared/Gatherable/GatherableSystem.cs b/Content.Shared/Gatherable/GatherableSystem.cs
new file mode 100644
index 0000000000..8c944cc8dc
--- /dev/null
+++ b/Content.Shared/Gatherable/GatherableSystem.cs
@@ -0,0 +1,115 @@
+using Content.Shared.Destructible;
+using Content.Shared.EntityTable;
+using Content.Shared.Gatherable.Components;
+using Content.Shared.Interaction;
+using Content.Shared.Projectiles;
+using Content.Shared.Random.Helpers;
+using Content.Shared.Tag;
+using Content.Shared.Weapons.Melee.Events;
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Physics.Events;
+using Robust.Shared.Timing;
+
+namespace Content.Shared.Gatherable;
+
+///
+/// System for handling collecting items from in-the-world structures,
+/// like gathering mushrooms or some rare herbs.
+///
+public sealed partial class GatherableSystem : EntitySystem
+{
+ [Dependency] private SharedAudioSystem _audio = default!;
+ [Dependency] private SharedDestructibleSystem _destructible = default!;
+ [Dependency] private EntityTableSystem _entityTable = default!;
+ [Dependency] private TagSystem _tagSystem = default!;
+ [Dependency] private IGameTiming _timing = default!;
+ [Dependency] private EntityWhitelistSystem _whitelistSystem = default!;
+
+ ///
+ /// If a gatherable entity is attacked with a tool that passes the tool entity whitelist (or if that whitelist doesn't exist), then it will be gathered.
+ ///
+ [SubscribeLocalEvent]
+ private void OnAttacked(Entity gatherable, ref AttackedEvent args)
+ {
+ if (_whitelistSystem.IsWhitelistFailOrNull(gatherable.Comp.ToolWhitelist, args.Used))
+ return;
+
+ Gather(gatherable.AsNullable(), args.User);
+ }
+
+ ///
+ /// If a gatherable entity is activated by a user that passes the tool entity whitelist (or if that whitelist doesn't exist), then it will be gathered.
+ ///
+ [SubscribeLocalEvent]
+ private void OnActivate(Entity gatherable, ref ActivateInWorldEvent args)
+ {
+ if (args.Handled || !args.Complex)
+ return;
+
+ if (_whitelistSystem.IsWhitelistFailOrNull(gatherable.Comp.ToolWhitelist, args.User))
+ return;
+
+ Gather(gatherable.AsNullable(), args.User);
+ args.Handled = true;
+ }
+
+ ///
+ /// If a projectile hits a gatherable entity, it will gather that entity and decrease its own gathering amount.
+ /// If the projectile’s gathering amount is zero or less, it will be deleted.
+ ///
+ [SubscribeLocalEvent]
+ private void OnProjectileCollide(Entity gathering, ref StartCollideEvent args)
+ {
+ if (!args.OtherFixture.Hard)
+ return;
+
+ if (args.OurFixtureId != SharedProjectileSystem.ProjectileFixture)
+ return;
+
+ if (gathering.Comp.Amount <= 0)
+ return;
+
+ if (!TryComp(args.OtherEntity, out var gatherable))
+ return;
+
+ Gather((args.OtherEntity, gatherable), gathering);
+ gathering.Comp.Amount--;
+ Dirty(gathering);
+
+ if (gathering.Comp.Amount <= 0)
+ PredictedQueueDel(gathering);
+ }
+
+ ///
+ /// Destroys the gathered entity, plays a sound if it has , and spawns loot if possible.
+ ///
+ /// The entity that was gathered.
+ /// The entity that gathered it.
+ public void Gather(Entity gathered, EntityUid? gatherer = null)
+ {
+ if (!Resolve(gathered, ref gathered.Comp))
+ return;
+
+ var pos = Transform(gathered).Coordinates;
+ if (TryComp(gathered, out var soundComp))
+ _audio.PlayPredicted(soundComp.Sound, pos, gatherer);
+
+ _destructible.DestroyEntity(gathered);
+
+ if (gathered.Comp.Loot is null)
+ return;
+
+ foreach (var (tag, table) in gathered.Comp.Loot)
+ {
+ if (tag != "All" && gatherer != null && !_tagSystem.HasTag(gatherer.Value, tag))
+ continue;
+
+ var random = SharedRandomExtensions.PredictedRandom(_timing, GetNetEntity(gathered));
+ foreach (var loot in _entityTable.GetSpawns(table, random))
+ {
+ PredictedSpawnAtPosition(loot, pos.Offset(random.NextVector2(gathered.Comp.GatherOffset)));
+ }
+ }
+ }
+}