diff --git a/Content.Server/Nutrition/Components/MessyDrinkerComponent.cs b/Content.Server/Nutrition/Components/MessyDrinkerComponent.cs
new file mode 100644
index 0000000000..6a1a3a0319
--- /dev/null
+++ b/Content.Server/Nutrition/Components/MessyDrinkerComponent.cs
@@ -0,0 +1,22 @@
+using Content.Shared.FixedPoint;
+
+namespace Content.Server.Nutrition.Components;
+
+///
+/// Entities with this component occasionally spill some of their drink when drinking.
+///
+[RegisterComponent]
+public sealed partial class MessyDrinkerComponent : Component
+{
+ [DataField]
+ public float SpillChance = 0.2f;
+
+ ///
+ /// The amount of solution that is spilled when procs.
+ ///
+ [DataField]
+ public FixedPoint2 SpillAmount = 1.0;
+
+ [DataField]
+ public LocId? SpillMessagePopup;
+}
diff --git a/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs b/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs
index deb49ea668..6e1824c843 100644
--- a/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs
+++ b/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs
@@ -2,6 +2,7 @@ using Content.Server.Body.Systems;
using Content.Server.Fluids.EntitySystems;
using Content.Server.Forensics;
using Content.Server.Inventory;
+using Content.Server.Nutrition.Events;
using Content.Server.Popups;
using Content.Shared.Administration.Logs;
using Content.Shared.Body.Components;
@@ -242,11 +243,18 @@ public sealed class DrinkSystem : SharedDrinkSystem
_audio.PlayPvs(entity.Comp.UseSound, args.Target.Value, AudioParams.Default.WithVolume(-2f).WithVariation(0.25f));
- _reaction.DoEntityReaction(args.Target.Value, solution, ReactionMethod.Ingestion);
- _stomach.TryTransferSolution(firstStomach.Value.Owner, drained, firstStomach.Value.Comp1);
+ var beforeDrinkEvent = new BeforeIngestDrinkEvent(entity.Owner, drained, forceDrink);
+ RaiseLocalEvent(args.Target.Value, ref beforeDrinkEvent);
_forensics.TransferDna(entity, args.Target.Value);
+ _reaction.DoEntityReaction(args.Target.Value, solution, ReactionMethod.Ingestion);
+
+ if (drained.Volume == 0)
+ return;
+
+ _stomach.TryTransferSolution(firstStomach.Value.Owner, drained, firstStomach.Value.Comp1);
+
if (!forceDrink && solution.Volume > 0)
args.Repeat = true;
}
diff --git a/Content.Server/Nutrition/EntitySystems/MessyDrinkerSystem.cs b/Content.Server/Nutrition/EntitySystems/MessyDrinkerSystem.cs
new file mode 100644
index 0000000000..f92318d0f7
--- /dev/null
+++ b/Content.Server/Nutrition/EntitySystems/MessyDrinkerSystem.cs
@@ -0,0 +1,41 @@
+using Content.Server.Fluids.EntitySystems;
+using Content.Server.Nutrition.Components;
+using Content.Server.Nutrition.Events;
+using Content.Shared.Popups;
+using Robust.Shared.Random;
+
+namespace Content.Server.Nutrition.EntitySystems;
+
+public sealed class MessyDrinkerSystem : EntitySystem
+{
+ [Dependency] private readonly PuddleSystem _puddle = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnBeforeIngestDrink);
+ }
+
+ private void OnBeforeIngestDrink(Entity ent, ref BeforeIngestDrinkEvent ev)
+ {
+ if (ev.Solution.Volume <= ent.Comp.SpillAmount)
+ return;
+
+ // Cannot spill if you're being forced to drink.
+ if (ev.Forced)
+ return;
+
+ if (!_random.Prob(ent.Comp.SpillChance))
+ return;
+
+ if (ent.Comp.SpillMessagePopup != null)
+ _popup.PopupEntity(Loc.GetString(ent.Comp.SpillMessagePopup), ent, ent, PopupType.MediumCaution);
+
+ var split = ev.Solution.SplitSolution(ent.Comp.SpillAmount);
+
+ _puddle.TrySpillAt(ent, split, out _);
+ }
+}
diff --git a/Content.Server/Nutrition/Events/DrinkEvents.cs b/Content.Server/Nutrition/Events/DrinkEvents.cs
new file mode 100644
index 0000000000..b7a7403105
--- /dev/null
+++ b/Content.Server/Nutrition/Events/DrinkEvents.cs
@@ -0,0 +1,12 @@
+using Content.Shared.Chemistry.Components;
+
+namespace Content.Server.Nutrition.Events;
+
+///
+/// Raised on the entity drinking. This is right before they actually transfer the solution into the stomach.
+///
+/// The drink that is being drank.
+/// The solution that will be digested.
+/// Whether the target was forced to drink the solution by somebody else.
+[ByRefEvent]
+public record struct BeforeIngestDrinkEvent(EntityUid Drink, Solution Solution, bool Forced);
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
index 41a66d2ef0..920468605f 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
@@ -2859,6 +2859,7 @@
- type: HTN
rootTask:
task: RuminantHostileCompound
+ - type: MessyDrinker
- type: Tag
tags:
- VimPilot
@@ -2905,6 +2906,7 @@
gender: epicene
- type: MobPrice
price: 200
+ - type: MessyDrinker
- type: entity
parent: MobCorgiBase
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
index 75aacadd35..dcfd6b41fc 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
@@ -370,6 +370,7 @@
- VimPilot
- type: StealTarget
stealGroup: AnimalMcGriff
+ - type: MessyDrinker
- type: entity
name: Paperwork
@@ -466,6 +467,7 @@
- type: Speech
speechVerb: Canine
speechSounds: Dog
+ - type: MessyDrinker
- type: entity
name: Morty