diff --git a/Content.Server/Kitchen/EntitySystems/SharpSystem.cs b/Content.Server/Kitchen/EntitySystems/SharpSystem.cs index cce5fb5bd3..0ba9d0990a 100644 --- a/Content.Server/Kitchen/EntitySystems/SharpSystem.cs +++ b/Content.Server/Kitchen/EntitySystems/SharpSystem.cs @@ -38,7 +38,7 @@ public sealed class SharpSystem : EntitySystem { base.Initialize(); - SubscribeLocalEvent(OnAfterInteract, before: [typeof(UtensilSystem)]); + SubscribeLocalEvent(OnAfterInteract, before: [typeof(IngestionSystem)]); SubscribeLocalEvent(OnDoAfter); SubscribeLocalEvent>(OnGetInteractionVerbs); diff --git a/Content.Server/NPC/Systems/NPCUtilitySystem.cs b/Content.Server/NPC/Systems/NPCUtilitySystem.cs index 489ac6de55..6de26cd056 100644 --- a/Content.Server/NPC/Systems/NPCUtilitySystem.cs +++ b/Content.Server/NPC/Systems/NPCUtilitySystem.cs @@ -44,9 +44,9 @@ public sealed class NPCUtilitySystem : EntitySystem [Dependency] private readonly ContainerSystem _container = default!; [Dependency] private readonly DrinkSystem _drink = default!; [Dependency] private readonly EntityLookupSystem _lookup = default!; - [Dependency] private readonly FoodSystem _food = default!; [Dependency] private readonly HandsSystem _hands = default!; [Dependency] private readonly InventorySystem _inventory = default!; + [Dependency] private readonly IngestionSystem _ingestion = default!; [Dependency] private readonly MobStateSystem _mobState = default!; [Dependency] private readonly NpcFactionSystem _npcFaction = default!; [Dependency] private readonly OpenableSystem _openable = default!; @@ -174,14 +174,8 @@ public sealed class NPCUtilitySystem : EntitySystem { case FoodValueCon: { - if (!TryComp(targetUid, out var food)) - return 0f; - - // mice can't eat unpeeled bananas, need monkey's help - if (_openable.IsClosed(targetUid)) - return 0f; - - if (!_food.IsDigestibleBy(owner, targetUid, food)) + // do we have a mouth available? Is the food item opened? + if (!_ingestion.CanConsume(owner, targetUid)) return 0f; var avoidBadFood = !HasComp(owner); @@ -194,15 +188,16 @@ public sealed class NPCUtilitySystem : EntitySystem if (avoidBadFood && HasComp(targetUid)) return 0f; + var nutrition = _ingestion.TotalNutrition(targetUid, owner); + if (nutrition <= 1.0f) + return 0f; + return 1f; } case DrinkValueCon: { - if (!TryComp(targetUid, out var drink)) - return 0f; - - // can't drink closed drinks - if (_openable.IsClosed(targetUid)) + // can't drink closed drinks and can't drink with a mask on... + if (!_ingestion.CanConsume(owner, targetUid)) return 0f; // only drink when thirsty @@ -214,7 +209,9 @@ public sealed class NPCUtilitySystem : EntitySystem return 0f; // needs to have something that will satiate thirst, mice wont try to drink 100% pure mutagen. - var hydration = _drink.TotalHydration(targetUid, drink); + // We don't check if the solution is metabolizable cause all drinks should be currently. + // If that changes then simply use the other overflow. + var hydration = _ingestion.TotalHydration(targetUid); if (hydration <= 1.0f) return 0f; diff --git a/Content.Server/Nutrition/Components/MessyDrinkerComponent.cs b/Content.Server/Nutrition/Components/MessyDrinkerComponent.cs index 6a1a3a0319..5519c5d983 100644 --- a/Content.Server/Nutrition/Components/MessyDrinkerComponent.cs +++ b/Content.Server/Nutrition/Components/MessyDrinkerComponent.cs @@ -1,9 +1,11 @@ using Content.Shared.FixedPoint; +using Content.Shared.Nutrition.Prototypes; +using Robust.Shared.Prototypes; namespace Content.Server.Nutrition.Components; /// -/// Entities with this component occasionally spill some of their drink when drinking. +/// Entities with this component occasionally spill some of the solution they're ingesting. /// [RegisterComponent] public sealed partial class MessyDrinkerComponent : Component @@ -17,6 +19,12 @@ public sealed partial class MessyDrinkerComponent : Component [DataField] public FixedPoint2 SpillAmount = 1.0; + /// + /// The types of food prototypes we can spill + /// + [DataField] + public List> SpillableTypes = new List> { "Drink" }; + [DataField] public LocId? SpillMessagePopup; } diff --git a/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs b/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs index 6e1824c843..1677f1d822 100644 --- a/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs +++ b/Content.Server/Nutrition/EntitySystems/DrinkSystem.cs @@ -1,50 +1,16 @@ -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; -using Content.Shared.Body.Systems; -using Content.Shared.Chemistry; using Content.Shared.Chemistry.Components; using Content.Shared.Chemistry.Components.SolutionManager; using Content.Shared.Chemistry.EntitySystems; -using Content.Shared.Chemistry.Reagent; -using Content.Shared.Database; -using Content.Shared.EntityEffects.Effects; -using Content.Shared.FixedPoint; -using Content.Shared.IdentityManagement; -using Content.Shared.Interaction; -using Content.Shared.Interaction.Events; -using Content.Shared.Nutrition; using Content.Shared.Nutrition.Components; using Content.Shared.Nutrition.EntitySystems; -using Robust.Shared.Audio; -using Robust.Shared.Audio.Systems; -using Robust.Shared.Player; -using Robust.Shared.Prototypes; -using Robust.Shared.Utility; + namespace Content.Server.Nutrition.EntitySystems; public sealed class DrinkSystem : SharedDrinkSystem { - [Dependency] private readonly BodySystem _body = default!; - [Dependency] private readonly FoodSystem _food = default!; - [Dependency] private readonly IPrototypeManager _proto = default!; - [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; - [Dependency] private readonly OpenableSystem _openable = default!; - [Dependency] private readonly PopupSystem _popup = default!; - [Dependency] private readonly PuddleSystem _puddle = default!; - [Dependency] private readonly ReactiveSystem _reaction = default!; [Dependency] private readonly SharedAppearanceSystem _appearance = default!; - [Dependency] private readonly SharedAudioSystem _audio = default!; - [Dependency] private readonly SharedInteractionSystem _interaction = default!; [Dependency] private readonly SharedSolutionContainerSystem _solutionContainer = default!; - [Dependency] private readonly StomachSystem _stomach = default!; - [Dependency] private readonly ForensicsSystem _forensics = default!; public override void Initialize() { @@ -55,59 +21,6 @@ public sealed class DrinkSystem : SharedDrinkSystem SubscribeLocalEvent(OnDrinkInit); // run before inventory so for bucket it always tries to drink before equipping (when empty) // run after openable so its always open -> drink - SubscribeLocalEvent(OnUse, before: [typeof(ServerInventorySystem)], after: [typeof(OpenableSystem)]); - SubscribeLocalEvent(AfterInteract); - SubscribeLocalEvent(OnDoAfter); - } - - /// - /// Get the total hydration factor contained in a drink's solution. - /// - public float TotalHydration(EntityUid uid, DrinkComponent? comp = null) - { - if (!Resolve(uid, ref comp)) - return 0f; - - if (!_solutionContainer.TryGetSolution(uid, comp.Solution, out _, out var solution)) - return 0f; - - var total = 0f; - foreach (var quantity in solution.Contents) - { - var reagent = _proto.Index(quantity.Reagent.Prototype); - if (reagent.Metabolisms == null) - continue; - - foreach (var entry in reagent.Metabolisms.Values) - { - foreach (var effect in entry.Effects) - { - // ignores any effect conditions, just cares about how much it can hydrate - if (effect is SatiateThirst thirst) - { - total += thirst.HydrationFactor * quantity.Quantity.Float(); - } - } - } - } - - return total; - } - - private void AfterInteract(Entity entity, ref AfterInteractEvent args) - { - if (args.Handled || args.Target == null || !args.CanReach) - return; - - args.Handled = TryDrink(args.User, args.Target.Value, entity.Comp, entity); - } - - private void OnUse(Entity entity, ref UseInHandEvent args) - { - if (args.Handled) - return; - - args.Handled = TryDrink(args.User, args.User, entity.Comp, entity); } private void OnDrinkInit(Entity entity, ref ComponentInit args) @@ -147,115 +60,4 @@ public sealed class DrinkSystem : SharedDrinkSystem var drainAvailable = DrinkVolume(uid, component); _appearance.SetData(uid, FoodVisuals.Visual, drainAvailable.Float(), appearance); } - - /// - /// Raised directed at a victim when someone has force fed them a drink. - /// - private void OnDoAfter(Entity entity, ref ConsumeDoAfterEvent args) - { - if (args.Handled || args.Cancelled || entity.Comp.Deleted) - return; - - if (!TryComp(args.Target, out var body)) - return; - - if (args.Used is null || !_solutionContainer.TryGetSolution(args.Used.Value, args.Solution, out var soln, out var solution)) - return; - - if (_openable.IsClosed(args.Used.Value, args.Target.Value, predicted: true)) - return; - - // TODO this should really be checked every tick. - if (_food.IsMouthBlocked(args.Target.Value)) - return; - - // TODO this should really be checked every tick. - if (!_interaction.InRangeUnobstructed(args.User, args.Target.Value)) - return; - - var transferAmount = FixedPoint2.Min(entity.Comp.TransferAmount, solution.Volume); - var drained = _solutionContainer.SplitSolution(soln.Value, transferAmount); - var forceDrink = args.User != args.Target; - - args.Handled = true; - if (transferAmount <= 0) - return; - - if (!_body.TryGetBodyOrganEntityComps((args.Target.Value, body), out var stomachs)) - { - _popup.PopupEntity(Loc.GetString(forceDrink ? "drink-component-try-use-drink-cannot-drink-other" : "drink-component-try-use-drink-had-enough"), args.Target.Value, args.User); - - if (HasComp(args.Target.Value)) - { - _puddle.TrySpillAt(args.User, drained, out _); - return; - } - - _solutionContainer.Refill(args.Target.Value, soln.Value, drained); - return; - } - - var firstStomach = stomachs.FirstOrNull(stomach => _stomach.CanTransferSolution(stomach.Owner, drained, stomach.Comp1)); - - //All stomachs are full or can't handle whatever solution we have. - if (firstStomach == null) - { - _popup.PopupEntity(Loc.GetString("drink-component-try-use-drink-had-enough"), args.Target.Value, args.Target.Value); - - if (forceDrink) - { - _popup.PopupEntity(Loc.GetString("drink-component-try-use-drink-had-enough-other"), args.Target.Value, args.User); - _puddle.TrySpillAt(args.Target.Value, drained, out _); - } - else - _solutionContainer.TryAddSolution(soln.Value, drained); - - return; - } - - var flavors = args.FlavorMessage; - - if (forceDrink) - { - var targetName = Identity.Entity(args.Target.Value, EntityManager); - var userName = Identity.Entity(args.User, EntityManager); - - _popup.PopupEntity(Loc.GetString("drink-component-force-feed-success", ("user", userName), ("flavors", flavors)), args.Target.Value, args.Target.Value); - - _popup.PopupEntity( - Loc.GetString("drink-component-force-feed-success-user", ("target", targetName)), - args.User, args.User); - - // log successful forced drinking - _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{ToPrettyString(entity.Owner):user} forced {ToPrettyString(args.User):target} to drink {ToPrettyString(entity.Owner):drink}"); - } - else - { - _popup.PopupEntity( - Loc.GetString("drink-component-try-use-drink-success-slurp-taste", ("flavors", flavors)), args.User, - args.User); - _popup.PopupEntity( - Loc.GetString("drink-component-try-use-drink-success-slurp"), args.User, Filter.PvsExcept(args.User), true); - - // log successful voluntary drinking - _adminLogger.Add(LogType.Ingestion, LogImpact.Low, $"{ToPrettyString(args.User):target} drank {ToPrettyString(entity.Owner):drink}"); - } - - _audio.PlayPvs(entity.Comp.UseSound, args.Target.Value, AudioParams.Default.WithVolume(-2f).WithVariation(0.25f)); - - 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 index f92318d0f7..dc8c11bb7f 100644 --- a/Content.Server/Nutrition/EntitySystems/MessyDrinkerSystem.cs +++ b/Content.Server/Nutrition/EntitySystems/MessyDrinkerSystem.cs @@ -1,6 +1,7 @@ using Content.Server.Fluids.EntitySystems; using Content.Server.Nutrition.Components; -using Content.Server.Nutrition.Events; +using Content.Shared.Nutrition; +using Content.Shared.Nutrition.EntitySystems; using Content.Shared.Popups; using Robust.Shared.Random; @@ -8,24 +9,30 @@ 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 IngestionSystem _ingestion = default!; + [Dependency] private readonly PuddleSystem _puddle = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; public override void Initialize() { base.Initialize(); - SubscribeLocalEvent(OnBeforeIngestDrink); + SubscribeLocalEvent(OnIngested); } - private void OnBeforeIngestDrink(Entity ent, ref BeforeIngestDrinkEvent ev) + private void OnIngested(Entity ent, ref IngestingEvent ev) { - if (ev.Solution.Volume <= ent.Comp.SpillAmount) + if (ev.Split.Volume <= ent.Comp.SpillAmount) + return; + + var proto = _ingestion.GetEdibleType(ev.Food); + + if (proto == null || !ent.Comp.SpillableTypes.Contains(proto.Value)) return; // Cannot spill if you're being forced to drink. - if (ev.Forced) + if (ev.ForceFed) return; if (!_random.Prob(ent.Comp.SpillChance)) @@ -34,7 +41,7 @@ public sealed class MessyDrinkerSystem : EntitySystem if (ent.Comp.SpillMessagePopup != null) _popup.PopupEntity(Loc.GetString(ent.Comp.SpillMessagePopup), ent, ent, PopupType.MediumCaution); - var split = ev.Solution.SplitSolution(ent.Comp.SpillAmount); + var split = ev.Split.SplitSolution(ent.Comp.SpillAmount); _puddle.TrySpillAt(ent, split, out _); } diff --git a/Content.Server/Nutrition/EntitySystems/SliceableFoodSystem.cs b/Content.Server/Nutrition/EntitySystems/SliceableFoodSystem.cs index 3ce285b06c..2a13d07797 100644 --- a/Content.Server/Nutrition/EntitySystems/SliceableFoodSystem.cs +++ b/Content.Server/Nutrition/EntitySystems/SliceableFoodSystem.cs @@ -22,6 +22,7 @@ public sealed class SliceableFoodSystem : EntitySystem { [Dependency] private readonly SharedSolutionContainerSystem _solutionContainer = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly SharedDestructibleSystem _destroy = default!; [Dependency] private readonly TransformSystem _transform = default!; [Dependency] private readonly DoAfterSystem _doAfter = default!; [Dependency] private readonly IRobustRandom _random = default!; @@ -64,31 +65,27 @@ public sealed class SliceableFoodSystem : EntitySystem if (args.Cancelled || args.Handled || args.Args.Target == null) return; - if (TrySliceFood(entity, args.User, args.Used, entity.Comp)) + if (TrySliceFood(entity.Owner, args.User, args.Used)) args.Handled = true; } - private bool TrySliceFood(EntityUid uid, + private bool TrySliceFood(Entity entity, EntityUid user, - EntityUid? usedItem, - SliceableFoodComponent? component = null, - FoodComponent? food = null, - TransformComponent? transform = null) + EntityUid? usedItem) { - if (!Resolve(uid, ref component, ref food, ref transform) || - string.IsNullOrEmpty(component.Slice)) + if (!Resolve(entity, ref entity.Comp1, ref entity.Comp2, ref entity.Comp3) || string.IsNullOrEmpty(entity.Comp2.Slice)) return false; - if (!_solutionContainer.TryGetSolution(uid, food.Solution, out var soln, out var solution)) + if (!_solutionContainer.TryGetSolution(entity.Owner, entity.Comp3.Solution, out var soln, out var solution)) return false; if (!TryComp(usedItem, out var utensil) || (utensil.Types & UtensilType.Knife) == 0) return false; - var sliceVolume = solution.Volume / FixedPoint2.New(component.TotalCount); - for (int i = 0; i < component.TotalCount; i++) + var sliceVolume = solution.Volume / FixedPoint2.New(entity.Comp2.TotalCount); + for (int i = 0; i < entity.Comp2.TotalCount; i++) { - var sliceUid = Slice(uid, user, component, transform); + var sliceUid = Slice(entity, user); var lostSolution = _solutionContainer.SplitSolution(soln.Value, sliceVolume); @@ -97,11 +94,11 @@ public sealed class SliceableFoodSystem : EntitySystem FillSlice(sliceUid, lostSolution); } - _audio.PlayPvs(component.Sound, transform.Coordinates, AudioParams.Default.WithVolume(-2)); + _audio.PlayPvs(entity.Comp2.Sound, entity.Comp1.Coordinates, AudioParams.Default.WithVolume(-2)); var ev = new SliceFoodEvent(); - RaiseLocalEvent(uid, ref ev); + RaiseLocalEvent(entity, ref ev); - DeleteFood(uid, user, food); + DeleteFood(entity, user); return true; } @@ -109,19 +106,16 @@ public sealed class SliceableFoodSystem : EntitySystem /// Create a new slice in the world and returns its entity. /// The solutions must be set afterwards. /// - public EntityUid Slice(EntityUid uid, - EntityUid user, - SliceableFoodComponent? comp = null, - TransformComponent? transform = null) + public EntityUid Slice(Entity entity, EntityUid user) { - if (!Resolve(uid, ref comp, ref transform)) + if (!Resolve(entity, ref entity.Comp1, ref entity.Comp2)) return EntityUid.Invalid; - var sliceUid = Spawn(comp.Slice, _transform.GetMapCoordinates(uid)); + var sliceUid = Spawn(entity.Comp2.Slice, _transform.GetMapCoordinates((entity, entity.Comp1))); // try putting the slice into the container if the food being sliced is in a container! // this lets you do things like slice a pizza up inside of a hot food cart without making a food-everywhere mess - _transform.DropNextTo(sliceUid, (uid, transform)); + _transform.DropNextTo(sliceUid, entity); _transform.SetLocalRotation(sliceUid, 0); if (!_container.IsEntityOrParentInContainer(sliceUid)) @@ -134,7 +128,7 @@ public sealed class SliceableFoodSystem : EntitySystem return sliceUid; } - private void DeleteFood(EntityUid uid, EntityUid user, FoodComponent foodComp) + private void DeleteFood(EntityUid uid, EntityUid user) { var ev = new BeforeFullySlicedEvent { @@ -144,38 +138,32 @@ public sealed class SliceableFoodSystem : EntitySystem if (ev.Cancelled) return; - var dev = new DestructionEventArgs(); - RaiseLocalEvent(uid, dev); - - // Locate the sliced food and spawn its trash - foreach (var trash in foodComp.Trash) - { - var trashUid = Spawn(trash, _transform.GetMapCoordinates(uid)); - - // try putting the trash in the food's container too, to be consistent with slice spawning? - _transform.DropNextTo(trashUid, uid); - _transform.SetLocalRotation(trashUid, 0); - } - - QueueDel(uid); + _destroy.DestroyEntity(uid); } - private void FillSlice(EntityUid sliceUid, Solution solution) + private void FillSlice(Entity slice, Solution solution) { - // Replace all reagents on prototype not just copying poisons (example: slices of eaten pizza should have less nutrition) - if (TryComp(sliceUid, out var sliceFoodComp) && - _solutionContainer.TryGetSolution(sliceUid, sliceFoodComp.Solution, out var itsSoln, out var itsSolution)) - { - _solutionContainer.RemoveAllSolution(itsSoln.Value); + if (!Resolve(slice, ref slice.Comp, false)) + return; - var lostSolutionPart = solution.SplitSolution(itsSolution.AvailableVolume); - _solutionContainer.TryAddSolution(itsSoln.Value, lostSolutionPart); - } + // Replace all reagents on prototype not just copying poisons (example: slices of eaten pizza should have less nutrition) + if (!_solutionContainer.TryGetSolution(slice.Owner, slice.Comp.Solution, out var itsSoln, out var itsSolution)) + return; + + _solutionContainer.RemoveAllSolution(itsSoln.Value); + + var lostSolutionPart = solution.SplitSolution(itsSolution.AvailableVolume); + _solutionContainer.TryAddSolution(itsSoln.Value, lostSolutionPart); } private void OnComponentStartup(Entity entity, ref ComponentStartup args) { - var foodComp = EnsureComp(entity); + // TODO: When Food Component is fully kill delete this awful method + // This exists just to make tests fail I guess, awesome! + // If you're here because your test just failed, make sure that: + // Your food has the edible component + // The solution listed in the edible component exists + var foodComp = EnsureComp(entity); _solutionContainer.EnsureSolution(entity.Owner, foodComp.Solution, out _); } } diff --git a/Content.Server/Nutrition/EntitySystems/SmokingSystem.Vape.cs b/Content.Server/Nutrition/EntitySystems/SmokingSystem.Vape.cs index 5a269eace5..5de6a5a631 100644 --- a/Content.Server/Nutrition/EntitySystems/SmokingSystem.Vape.cs +++ b/Content.Server/Nutrition/EntitySystems/SmokingSystem.Vape.cs @@ -22,7 +22,7 @@ namespace Content.Server.Nutrition.EntitySystems [Dependency] private readonly DoAfterSystem _doAfterSystem = default!; [Dependency] private readonly DamageableSystem _damageableSystem = default!; [Dependency] private readonly EmagSystem _emag = default!; - [Dependency] private readonly FoodSystem _foodSystem = default!; + [Dependency] private readonly IngestionSystem _ingestion = default!; [Dependency] private readonly ExplosionSystem _explosionSystem = default!; [Dependency] private readonly PopupSystem _popupSystem = default!; @@ -42,7 +42,8 @@ namespace Content.Server.Nutrition.EntitySystems if (!args.CanReach || !_solutionContainerSystem.TryGetRefillableSolution(entity.Owner, out _, out var solution) || !HasComp(args.Target) - || _foodSystem.IsMouthBlocked(args.Target.Value, args.User)) + || _ingestion.HasMouthAvailable(args.Target.Value, args.User) + ) { return; } diff --git a/Content.Server/Polymorph/Systems/PolymorphSystem.cs b/Content.Server/Polymorph/Systems/PolymorphSystem.cs index 6b6284b18e..d0f8b4e8fc 100644 --- a/Content.Server/Polymorph/Systems/PolymorphSystem.cs +++ b/Content.Server/Polymorph/Systems/PolymorphSystem.cs @@ -53,7 +53,6 @@ public sealed partial class PolymorphSystem : EntitySystem SubscribeLocalEvent(OnPolymorphActionEvent); SubscribeLocalEvent(OnRevertPolymorphActionEvent); - SubscribeLocalEvent(OnBeforeFullyEaten); SubscribeLocalEvent(OnBeforeFullySliced); SubscribeLocalEvent(OnDestruction); @@ -126,16 +125,6 @@ public sealed partial class PolymorphSystem : EntitySystem Revert((ent, ent)); } - private void OnBeforeFullyEaten(Entity ent, ref BeforeFullyEatenEvent args) - { - var (_, comp) = ent; - if (comp.Configuration.RevertOnEat) - { - args.Cancel(); - Revert((ent, ent)); - } - } - private void OnBeforeFullySliced(Entity ent, ref BeforeFullySlicedEvent args) { var (_, comp) = ent; diff --git a/Content.Shared/Animals/WoolySystem.cs b/Content.Shared/Animals/WoolySystem.cs index 734de2f34c..203def2257 100644 --- a/Content.Shared/Animals/WoolySystem.cs +++ b/Content.Shared/Animals/WoolySystem.cs @@ -23,7 +23,6 @@ public sealed class WoolySystem : EntitySystem { base.Initialize(); - SubscribeLocalEvent(OnBeforeFullyEaten); SubscribeLocalEvent(OnMapInit); SubscribeLocalEvent(OnEntRemoved); } @@ -77,10 +76,4 @@ public sealed class WoolySystem : EntitySystem _solutionContainer.TryAddReagent(wooly.Solution.Value, wooly.ReagentId, wooly.Quantity, out _); } } - - private void OnBeforeFullyEaten(Entity ent, ref BeforeFullyEatenEvent args) - { - // don't want moths to delete goats after eating them - args.Cancel(); - } } diff --git a/Content.Shared/Damage/Systems/SharedGodmodeSystem.cs b/Content.Shared/Damage/Systems/SharedGodmodeSystem.cs index feb5dd4140..4bf762c479 100644 --- a/Content.Shared/Damage/Systems/SharedGodmodeSystem.cs +++ b/Content.Shared/Damage/Systems/SharedGodmodeSystem.cs @@ -1,6 +1,7 @@ using Content.Shared.Damage.Components; using Content.Shared.Damage.Events; using Content.Shared.Destructible; +using Content.Shared.Nutrition; using Content.Shared.Prototypes; using Content.Shared.Rejuvenate; using Content.Shared.Slippery; @@ -24,6 +25,7 @@ public abstract class SharedGodmodeSystem : EntitySystem SubscribeLocalEvent(OnBeforeStatusEffect); SubscribeLocalEvent(OnBeforeOldStatusEffect); SubscribeLocalEvent(OnBeforeStaminaDamage); + SubscribeLocalEvent(BeforeEdible); SubscribeLocalEvent(OnSlipAttempt); SubscribeLocalEvent(OnDestruction); } @@ -60,6 +62,11 @@ public abstract class SharedGodmodeSystem : EntitySystem args.Cancel(); } + private void BeforeEdible(Entity ent, ref IngestibleEvent args) + { + args.Cancelled = true; + } + public virtual void EnableGodmode(EntityUid uid, GodmodeComponent? godmode = null) { godmode ??= EnsureComp(uid); diff --git a/Content.Shared/Inventory/InventorySystem.Relay.cs b/Content.Shared/Inventory/InventorySystem.Relay.cs index fe3267a92f..f4a0ccb5de 100644 --- a/Content.Shared/Inventory/InventorySystem.Relay.cs +++ b/Content.Shared/Inventory/InventorySystem.Relay.cs @@ -19,6 +19,7 @@ using Content.Shared.Inventory.Events; using Content.Shared.Movement.Events; using Content.Shared.Movement.Systems; using Content.Shared.NameModifier.EntitySystems; +using Content.Shared.Nutrition; using Content.Shared.Overlays; using Content.Shared.Projectiles; using Content.Shared.Radio; @@ -72,6 +73,7 @@ public partial class InventorySystem SubscribeLocalEvent(RefRelayInventoryEvent); SubscribeLocalEvent(RefRelayInventoryEvent); SubscribeLocalEvent(RefRelayInventoryEvent); + SubscribeLocalEvent(RefRelayInventoryEvent); // Eye/vision events SubscribeLocalEvent(RelayInventoryEvent); diff --git a/Content.Shared/Nutrition/Components/DrinkComponent.cs b/Content.Shared/Nutrition/Components/DrinkComponent.cs index 2211d58071..a4d1114379 100644 --- a/Content.Shared/Nutrition/Components/DrinkComponent.cs +++ b/Content.Shared/Nutrition/Components/DrinkComponent.cs @@ -5,6 +5,7 @@ using Robust.Shared.GameStates; namespace Content.Shared.Nutrition.Components; +[Obsolete("Migration to Content.Shared.Nutrition.Components.EdibleComponent is required")] [NetworkedComponent, AutoGenerateComponentState] [RegisterComponent, Access(typeof(SharedDrinkSystem))] public sealed partial class DrinkComponent : Component diff --git a/Content.Shared/Nutrition/Components/EdibleComponent.cs b/Content.Shared/Nutrition/Components/EdibleComponent.cs new file mode 100644 index 0000000000..4fcd9770eb --- /dev/null +++ b/Content.Shared/Nutrition/Components/EdibleComponent.cs @@ -0,0 +1,86 @@ +using Content.Shared.Body.Components; +using Content.Shared.FixedPoint; +using Content.Shared.Nutrition.EntitySystems; +using Content.Shared.Nutrition.Prototypes; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Nutrition.Components; + +/// +/// This is used on an entity with a solution container to flag a specific solution as being able to have its +/// reagents consumed directly. +/// +[RegisterComponent, NetworkedComponent, Access(typeof(IngestionSystem))] +public sealed partial class EdibleComponent : Component +{ + /// + /// Name of the solution that stores the consumable reagents + /// + [DataField] + public string Solution = "food"; + + /// + /// Should this entity be deleted when our solution is emptied? + /// + [DataField] + public bool DestroyOnEmpty = true; + + /// + /// Trash we spawn when eaten, will not spawn if the item isn't deleted when empty. + /// + [DataField] + public List Trash = new(); + + /// + /// How much of our solution is eaten on a do-after completion. Set to null to eat the whole thing. + /// + [DataField] + public FixedPoint2? TransferAmount = FixedPoint2.New(5); + + /// + /// Acceptable utensils to use + /// + [DataField] + public UtensilType Utensil = UtensilType.Fork; //There are more "solid" than "liquid" food + + /// + /// Do we need a utensil to access this solution? + /// + [DataField] + public bool UtensilRequired; + + /// + /// If this is set to true, food can only be eaten if you have a stomach with a + /// that includes this entity in its whitelist, + /// rather than just being digestible by anything that can eat food. + /// Whitelist the food component to allow eating of normal food. + /// + [DataField] + public bool RequiresSpecialDigestion; + + /// + /// How long it takes to eat the food personally. + /// + [DataField] + public TimeSpan Delay = TimeSpan.FromSeconds(1f); + + /// + /// This is how many seconds it takes to force-feed someone this food. + /// Should probably be smaller for small items like pills. + /// + [DataField] + public TimeSpan ForceFeedDelay = TimeSpan.FromSeconds(3f); + + /// + /// For mobs that are food, requires killing them before eating. + /// + [DataField] + public bool RequireDead = true; + + /// + /// Verb, icon, and sound data for our edible. + /// + [DataField] + public ProtoId Edible = IngestionSystem.Food; +} diff --git a/Content.Shared/Nutrition/Components/FoodComponent.cs b/Content.Shared/Nutrition/Components/FoodComponent.cs index ce04569fcb..5f1ec41717 100644 --- a/Content.Shared/Nutrition/Components/FoodComponent.cs +++ b/Content.Shared/Nutrition/Components/FoodComponent.cs @@ -5,7 +5,7 @@ using Robust.Shared.Audio; using Robust.Shared.Prototypes; namespace Content.Shared.Nutrition.Components; - +[Obsolete("Migration to Content.Shared.Nutrition.Components.EdibleComponent is required")] [RegisterComponent, Access(typeof(FoodSystem), typeof(FoodSequenceSystem))] public sealed partial class FoodComponent : Component { @@ -53,7 +53,7 @@ public sealed partial class FoodComponent : Component /// The localization identifier for the eat message. Needs a "food" entity argument passed to it. /// [DataField] - public LocId EatMessage = "food-nom"; + public LocId EatMessage = "edible-nom"; /// /// How long it takes to eat the food personally. diff --git a/Content.Shared/Nutrition/Components/IngestionBlockerComponent.cs b/Content.Shared/Nutrition/Components/IngestionBlockerComponent.cs index 803bf1f8b2..931d47838b 100644 --- a/Content.Shared/Nutrition/Components/IngestionBlockerComponent.cs +++ b/Content.Shared/Nutrition/Components/IngestionBlockerComponent.cs @@ -9,13 +9,12 @@ namespace Content.Shared.Nutrition.Components; /// In the event that more head-wear & mask functionality is added (like identity systems, or raising/lowering of /// masks), then this component might become redundant. /// -[RegisterComponent, Access(typeof(FoodSystem), typeof(SharedDrinkSystem), typeof(IngestionBlockerSystem))] +[RegisterComponent, Access(typeof(IngestionSystem))] public sealed partial class IngestionBlockerComponent : Component { /// /// Is this component currently blocking consumption. /// - [ViewVariables(VVAccess.ReadWrite)] - [DataField("enabled")] + [DataField] public bool Enabled { get; set; } = true; } diff --git a/Content.Shared/Nutrition/Components/OpenableComponent.cs b/Content.Shared/Nutrition/Components/OpenableComponent.cs index 58d6665c58..6a32c0bdeb 100644 --- a/Content.Shared/Nutrition/Components/OpenableComponent.cs +++ b/Content.Shared/Nutrition/Components/OpenableComponent.cs @@ -36,7 +36,7 @@ public sealed partial class OpenableComponent : Component /// Text shown when examining and its open. /// [DataField] - public LocId ExamineText = "drink-component-on-examine-is-opened"; + public LocId ExamineText = "openable-component-on-examine-is-opened"; /// /// The locale id for the popup shown when IsClosed is called and closed. Needs a "owner" entity argument passed to it. @@ -44,7 +44,7 @@ public sealed partial class OpenableComponent : Component /// It's still generic enough that you should change it if you make openable non-drinks, i.e. unwrap it first, peel it first. /// [DataField] - public LocId ClosedPopup = "drink-component-try-use-drink-not-open"; + public LocId ClosedPopup = "openable-component-try-use-closed"; /// /// Text to show in the verb menu for the "Open" action. diff --git a/Content.Shared/Nutrition/Components/SealableComponent.cs b/Content.Shared/Nutrition/Components/SealableComponent.cs index 1c2f732e7a..71a63f103b 100644 --- a/Content.Shared/Nutrition/Components/SealableComponent.cs +++ b/Content.Shared/Nutrition/Components/SealableComponent.cs @@ -22,11 +22,11 @@ public sealed partial class SealableComponent : Component /// Text shown when examining and the item's seal has not been broken. /// [DataField] - public LocId ExamineTextSealed = "drink-component-on-examine-is-sealed"; + public LocId ExamineTextSealed = "sealable-component-on-examine-is-sealed"; /// /// Text shown when examining and the item's seal has been broken. /// [DataField] - public LocId ExamineTextUnsealed = "drink-component-on-examine-is-unsealed"; + public LocId ExamineTextUnsealed = "sealable-component-on-examine-is-unsealed"; } diff --git a/Content.Shared/Nutrition/Components/UtensilComponent.cs b/Content.Shared/Nutrition/Components/UtensilComponent.cs index e8da588186..f3c4323592 100644 --- a/Content.Shared/Nutrition/Components/UtensilComponent.cs +++ b/Content.Shared/Nutrition/Components/UtensilComponent.cs @@ -4,7 +4,7 @@ using Robust.Shared.GameStates; namespace Content.Shared.Nutrition.Components { - [RegisterComponent, NetworkedComponent, Access(typeof(UtensilSystem))] + [RegisterComponent, NetworkedComponent, Access(typeof(IngestionSystem))] public sealed partial class UtensilComponent : Component { [DataField("types")] diff --git a/Content.Shared/Nutrition/EntitySystems/FlavorProfileSystem.cs b/Content.Shared/Nutrition/EntitySystems/FlavorProfileSystem.cs index 31384f3a18..e887486e93 100644 --- a/Content.Shared/Nutrition/EntitySystems/FlavorProfileSystem.cs +++ b/Content.Shared/Nutrition/EntitySystems/FlavorProfileSystem.cs @@ -19,22 +19,30 @@ public sealed class FlavorProfileSystem : EntitySystem private int FlavorLimit => _configManager.GetCVar(CCVars.FlavorLimit); - public string GetLocalizedFlavorsMessage(EntityUid uid, EntityUid user, Solution solution, - FlavorProfileComponent? flavorProfile = null) + public string GetLocalizedFlavorsMessage(Entity entity, EntityUid user, Solution? solution) { - if (!Resolve(uid, ref flavorProfile, false)) + HashSet flavors = new(); + HashSet? ignore = null; + + if (Resolve(entity, ref entity.Comp, false)) { - return Loc.GetString(BackupFlavorMessage); + flavors = entity.Comp.Flavors; + ignore = entity.Comp.IgnoreReagents; } - var flavors = new HashSet(flavorProfile.Flavors); - flavors.UnionWith(GetFlavorsFromReagents(solution, FlavorLimit - flavors.Count, flavorProfile.IgnoreReagents)); + + if (solution != null) + flavors.UnionWith(GetFlavorsFromReagents(solution, FlavorLimit - flavors.Count, ignore)); var ev = new FlavorProfileModificationEvent(user, flavors); + RaiseLocalEvent(ev); - RaiseLocalEvent(uid, ev); + RaiseLocalEvent(entity, ev); RaiseLocalEvent(user, ev); + if (flavors.Count == 0) + return Loc.GetString(BackupFlavorMessage); + return FlavorsToFlavorMessage(flavors); } diff --git a/Content.Shared/Nutrition/EntitySystems/FoodSystem.cs b/Content.Shared/Nutrition/EntitySystems/FoodSystem.cs index a8b5f7ac78..a599a1e74e 100644 --- a/Content.Shared/Nutrition/EntitySystems/FoodSystem.cs +++ b/Content.Shared/Nutrition/EntitySystems/FoodSystem.cs @@ -1,58 +1,36 @@ -using System.Linq; using Content.Shared.Administration.Logs; -using Content.Shared.Body.Components; -using Content.Shared.Body.Organ; -using Content.Shared.Body.Systems; -using Content.Shared.Chemistry; using Content.Shared.Chemistry.EntitySystems; -using Content.Shared.Containers.ItemSlots; using Content.Shared.Database; -using Content.Shared.Destructible; -using Content.Shared.DoAfter; -using Content.Shared.FixedPoint; -using Content.Shared.Hands.Components; +using Content.Shared.Forensics; using Content.Shared.Hands.EntitySystems; using Content.Shared.IdentityManagement; using Content.Shared.Interaction; -using Content.Shared.Interaction.Components; using Content.Shared.Interaction.Events; using Content.Shared.Inventory; using Content.Shared.Mobs.Systems; using Content.Shared.Nutrition.Components; using Content.Shared.Popups; -using Content.Shared.Stacks; -using Content.Shared.Storage; using Content.Shared.Verbs; -using Content.Shared.Whitelist; using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; -using Robust.Shared.Utility; namespace Content.Shared.Nutrition.EntitySystems; /// /// Handles feeding attempts both on yourself and on the target. /// +[Obsolete("Migration to Content.Shared.Nutrition.EntitySystems.IngestionSystem is required")] public sealed class FoodSystem : EntitySystem { - [Dependency] private readonly SharedBodySystem _body = default!; [Dependency] private readonly FlavorProfileSystem _flavorProfile = default!; + [Dependency] private readonly IngestionSystem _ingestion = default!; [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; - [Dependency] private readonly InventorySystem _inventory = default!; [Dependency] private readonly MobStateSystem _mobState = default!; - [Dependency] private readonly OpenableSystem _openable = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; - [Dependency] private readonly ReactiveSystem _reaction = default!; [Dependency] private readonly SharedAudioSystem _audio = default!; - [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; [Dependency] private readonly SharedHandsSystem _hands = default!; - [Dependency] private readonly SharedInteractionSystem _interaction = default!; [Dependency] private readonly SharedSolutionContainerSystem _solutionContainer = default!; [Dependency] private readonly SharedTransformSystem _transform = default!; - [Dependency] private readonly SharedStackSystem _stack = default!; - [Dependency] private readonly StomachSystem _stomach = default!; - [Dependency] private readonly UtensilSystem _utensil = default!; - [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!; public const float MaxFeedDistance = 1.0f; @@ -60,25 +38,35 @@ public sealed class FoodSystem : EntitySystem { base.Initialize(); - // TODO add InteractNoHandEvent for entities like mice. - // run after openable for wrapped/peelable foods SubscribeLocalEvent(OnUseFoodInHand, after: new[] { typeof(OpenableSystem), typeof(InventorySystem) }); SubscribeLocalEvent(OnFeedFood); + SubscribeLocalEvent>(AddEatVerb); - SubscribeLocalEvent(OnDoAfter); - SubscribeLocalEvent(OnInventoryIngestAttempt); + + SubscribeLocalEvent(OnBeforeFoodEaten); + SubscribeLocalEvent(OnFoodEaten); + SubscribeLocalEvent(OnFoodFullyEaten); + + SubscribeLocalEvent(OnGetUtensils); + + SubscribeLocalEvent(OnIsFoodDigestible); + + SubscribeLocalEvent(OnFood); + + SubscribeLocalEvent(OnGetEdibleType); + + SubscribeLocalEvent(OnBeforeFullySliced); } /// - /// Eat item + /// Eat or drink an item /// private void OnUseFoodInHand(Entity entity, ref UseInHandEvent ev) { if (ev.Handled) return; - var result = TryFeed(ev.User, ev.User, entity, entity.Comp); - ev.Handled = result.Handled; + ev.Handled = _ingestion.TryIngest(ev.User, ev.User, entity); } /// @@ -89,271 +77,98 @@ public sealed class FoodSystem : EntitySystem if (args.Handled || args.Target == null || !args.CanReach) return; - var result = TryFeed(args.User, args.Target.Value, entity, entity.Comp); - args.Handled = result.Handled; + args.Handled = _ingestion.TryIngest(args.User, args.Target.Value, entity); } - /// - /// Tries to feed the food item to the target entity - /// - public (bool Success, bool Handled) TryFeed(EntityUid user, EntityUid target, EntityUid food, FoodComponent foodComp) + private void AddEatVerb(Entity entity, ref GetVerbsEvent args) { - //Suppresses eating yourself and alive mobs - if (food == user || (_mobState.IsAlive(food) && foodComp.RequireDead)) - return (false, false); + var user = args.User; - // Target can't be fed or they're already eating - if (!TryComp(target, out var body)) - return (false, false); + if (entity.Owner == user || !args.CanInteract || !args.CanAccess) + return; - if (HasComp(food)) - return (false, false); + if (!_ingestion.TryGetIngestionVerb(user, entity, IngestionSystem.Food, out var verb)) + return; - if (_openable.IsClosed(food, user, predicted: true)) - return (false, true); - - if (!_solutionContainer.TryGetSolution(food, foodComp.Solution, out _, out var foodSolution)) - return (false, false); - - if (!_body.TryGetBodyOrganEntityComps((target, body), out var stomachs)) - return (false, false); - - // Check for special digestibles - if (!IsDigestibleBy(food, foodComp, stomachs)) - return (false, false); - - if (!TryGetRequiredUtensils(user, foodComp, out _)) - return (false, false); - - // Check for used storage on the food item - if (TryComp(food, out var storageState) && storageState.Container.ContainedEntities.Any()) - { - _popup.PopupClient(Loc.GetString("food-has-used-storage", ("food", food)), user, user); - return (false, true); - } - - // Checks for used item slots - if (TryComp(food, out var itemSlots)) - { - if (itemSlots.Slots.Any(slot => slot.Value.HasItem)) - { - _popup.PopupClient(Loc.GetString("food-has-used-storage", ("food", food)), user, user); - return (false, true); - } - } - - var flavors = _flavorProfile.GetLocalizedFlavorsMessage(food, user, foodSolution); - - if (GetUsesRemaining(food, foodComp) <= 0) - { - _popup.PopupClient(Loc.GetString("food-system-try-use-food-is-empty", ("entity", food)), user, user); - DeleteAndSpawnTrash(foodComp, food, user); - return (false, true); - } - - if (IsMouthBlocked(target, user)) - return (false, true); - - if (!_interaction.InRangeUnobstructed(user, food, popup: true)) - return (false, true); - - if (!_interaction.InRangeUnobstructed(user, target, MaxFeedDistance, popup: true)) - return (false, true); - - // TODO make do-afters account for fixtures in the range check. - if (!_transform.GetMapCoordinates(user).InRange(_transform.GetMapCoordinates(target), MaxFeedDistance)) - { - var message = Loc.GetString("interaction-system-user-interaction-cannot-reach"); - _popup.PopupClient(message, user, user); - return (false, true); - } - - var forceFeed = user != target; - if (forceFeed) - { - var userName = Identity.Entity(user, EntityManager); - _popup.PopupEntity(Loc.GetString("food-system-force-feed", ("user", userName)), - user, target); - - // logging - _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{ToPrettyString(user):user} is forcing {ToPrettyString(target):target} to eat {ToPrettyString(food):food} {SharedSolutionContainerSystem.ToPrettyString(foodSolution)}"); - } - else - { - // log voluntary eating - _adminLogger.Add(LogType.Ingestion, LogImpact.Low, $"{ToPrettyString(target):target} is eating {ToPrettyString(food):food} {SharedSolutionContainerSystem.ToPrettyString(foodSolution)}"); - } - - var doAfterArgs = new DoAfterArgs(EntityManager, - user, - forceFeed ? foodComp.ForceFeedDelay : foodComp.Delay, - new ConsumeDoAfterEvent(foodComp.Solution, flavors), - eventTarget: food, - target: target, - used: food) - { - BreakOnHandChange = false, - BreakOnMove = forceFeed, - BreakOnDamage = true, - MovementThreshold = 0.01f, - DistanceThreshold = MaxFeedDistance, - // do-after will stop if item is dropped when trying to feed someone else - // or if the item started out in the user's own hands - NeedHand = forceFeed || _hands.IsHolding(user, food), - }; - - _doAfter.TryStartDoAfter(doAfterArgs); - return (true, true); + args.Verbs.Add(verb); } - private void OnDoAfter(Entity entity, ref ConsumeDoAfterEvent args) + private void OnBeforeFoodEaten(Entity food, ref BeforeIngestedEvent args) { - if (args.Cancelled || args.Handled || entity.Comp.Deleted || args.Target == null) + if (args.Cancelled || args.Solution is not { } solution) return; - if (!TryComp(args.Target.Value, out var body)) - return; + // Set it to transfer amount if it exists, otherwise eat the whole volume if possible. + args.Transfer = food.Comp.TransferAmount ?? solution.Volume; + } - if (!_body.TryGetBodyOrganEntityComps((args.Target.Value, body), out var stomachs)) + private void OnFoodEaten(Entity entity, ref IngestedEvent args) + { + if (args.Handled) return; - if (args.Used is null || !_solutionContainer.TryGetSolution(args.Used.Value, args.Solution, out var soln, out var solution)) - return; - - if (!TryGetRequiredUtensils(args.User, entity.Comp, out var utensils)) - return; - - // TODO this should really be checked every tick. - if (IsMouthBlocked(args.Target.Value)) - return; - - // TODO this should really be checked every tick. - if (!_interaction.InRangeUnobstructed(args.User, args.Target.Value)) - return; - - var forceFeed = args.User != args.Target; - args.Handled = true; - var transferAmount = entity.Comp.TransferAmount != null ? FixedPoint2.Min((FixedPoint2) entity.Comp.TransferAmount, solution.Volume) : solution.Volume; - var split = _solutionContainer.SplitSolution(soln.Value, transferAmount); + _audio.PlayPredicted(entity.Comp.UseSound, args.Target, args.User, AudioParams.Default.WithVolume(-1f).WithVariation(0.20f)); - // Get the stomach with the highest available solution volume - var highestAvailable = FixedPoint2.Zero; - Entity? stomachToUse = null; - foreach (var ent in stomachs) + var flavors = _flavorProfile.GetLocalizedFlavorsMessage(entity.Owner, args.Target, args.Split); + + if (args.ForceFed) { - var owner = ent.Owner; - if (!_stomach.CanTransferSolution(owner, split, ent.Comp1)) - continue; - - if (!_solutionContainer.ResolveSolution(owner, StomachSystem.DefaultSolutionName, ref ent.Comp1.Solution, out var stomachSol)) - continue; - - if (stomachSol.AvailableVolume <= highestAvailable) - continue; - - stomachToUse = ent; - highestAvailable = stomachSol.AvailableVolume; - } - - // No stomach so just popup a message that they can't eat. - if (stomachToUse == null) - { - _solutionContainer.TryAddSolution(soln.Value, split); - _popup.PopupClient(forceFeed ? Loc.GetString("food-system-you-cannot-eat-any-more-other", ("target", args.Target.Value)) : Loc.GetString("food-system-you-cannot-eat-any-more"), args.Target.Value, args.User); - return; - } - - _reaction.DoEntityReaction(args.Target.Value, solution, ReactionMethod.Ingestion); - _stomach.TryTransferSolution(stomachToUse!.Value.Owner, split, stomachToUse); - - var flavors = args.FlavorMessage; - - if (forceFeed) - { - var targetName = Identity.Entity(args.Target.Value, EntityManager); + var targetName = Identity.Entity(args.Target, EntityManager); var userName = Identity.Entity(args.User, EntityManager); - _popup.PopupEntity(Loc.GetString("food-system-force-feed-success", ("user", userName), ("flavors", flavors)), entity.Owner, entity.Owner); + _popup.PopupEntity(Loc.GetString("edible-force-feed-success", ("user", userName), ("verb", _ingestion.GetProtoVerb(IngestionSystem.Food)), ("flavors", flavors)), entity, entity); - _popup.PopupClient(Loc.GetString("food-system-force-feed-success-user", ("target", targetName)), args.User, args.User); + _popup.PopupClient(Loc.GetString("edible-force-feed-success-user", ("target", targetName), ("verb", _ingestion.GetProtoVerb(IngestionSystem.Food))), args.User, args.User); - // log successful force feed - _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{ToPrettyString(entity.Owner):user} forced {ToPrettyString(args.User):target} to eat {ToPrettyString(entity.Owner):food}"); + // log successful forced feeding + _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{ToPrettyString(entity):user} forced {ToPrettyString(args.User):target} to eat {ToPrettyString(entity):food}"); } else { _popup.PopupClient(Loc.GetString(entity.Comp.EatMessage, ("food", entity.Owner), ("flavors", flavors)), args.User, args.User); // log successful voluntary eating - _adminLogger.Add(LogType.Ingestion, LogImpact.Low, $"{ToPrettyString(args.User):target} ate {ToPrettyString(entity.Owner):food}"); + _adminLogger.Add(LogType.Ingestion, LogImpact.Low, $"{ToPrettyString(args.User):target} ate {ToPrettyString(entity):food}"); } - _audio.PlayPredicted(entity.Comp.UseSound, args.Target.Value, args.User, entity.Comp.UseSound.Params.WithVolume(-1f).WithVariation(0.20f)); - - // Try to break all used utensils - foreach (var utensil in utensils) + // BREAK OUR UTENSILS + if (_ingestion.TryGetUtensils(args.User, entity, out var utensils)) { - _utensil.TryBreak(utensil, args.User); - } - - args.Repeat = !forceFeed; - - if (TryComp(entity, out var stack)) - { - //Not deleting whole stack piece will make troubles with grinding object - if (stack.Count > 1) + foreach (var utensil in utensils) { - _stack.SetCount(entity.Owner, stack.Count - 1); - _solutionContainer.TryAddSolution(soln.Value, split); - return; + _ingestion.TryBreak(utensil, args.User); } } - else if (GetUsesRemaining(entity.Owner, entity.Comp) > 0) + + if (_ingestion.GetUsesRemaining(entity, entity.Comp.Solution, args.Split.Volume) > 0) { + // Leave some of the consumer's DNA on the consumed item... + var ev = new TransferDnaEvent + { + Donor = args.Target, + Recipient = entity, + CanDnaBeCleaned = false, + }; + RaiseLocalEvent(args.Target, ref ev); + + args.Repeat = !args.ForceFed; return; } - // don't try to repeat if its being deleted - args.Repeat = false; - DeleteAndSpawnTrash(entity.Comp, entity.Owner, args.User); + // Food is always destroyed... + args.Destroy = true; } - public void DeleteAndSpawnTrash(FoodComponent component, EntityUid food, EntityUid user) + private void OnFoodFullyEaten(Entity food, ref FullyEatenEvent args) { - var ev = new BeforeFullyEatenEvent - { - User = user - }; - RaiseLocalEvent(food, ev); - if (ev.Cancelled) + if (food.Comp.Trash.Count == 0) return; - var attemptEv = new DestructionAttemptEvent(); - RaiseLocalEvent(food, attemptEv); - if (attemptEv.Cancelled) - return; - - var afterEvent = new AfterFullyEatenEvent(user); - RaiseLocalEvent(food, ref afterEvent); - - var dev = new DestructionEventArgs(); - RaiseLocalEvent(food, dev); - - if (component.Trash.Count == 0) - { - PredictedQueueDel(food); - return; - } - - //We're empty. Become trash. - //cache some data as we remove food, before spawning trash and passing it to the hand. - var position = _transform.GetMapCoordinates(food); - var trashes = component.Trash; - var tryPickup = _hands.IsHolding(user, food, out _); + var trashes = food.Comp.Trash; + var tryPickup = _hands.IsHolding(args.User, food, out _); - PredictedDel(food); foreach (var trash in trashes) { var spawnedTrash = EntityManager.PredictedSpawn(trash, position); @@ -362,192 +177,77 @@ public sealed class FoodSystem : EntitySystem if (tryPickup) { // Put the trash in the user's hand - _hands.TryPickupAnyHand(user, spawnedTrash); + _hands.TryPickupAnyHand(args.User, spawnedTrash); } } } - private void AddEatVerb(Entity entity, ref GetVerbsEvent ev) - { - if (entity.Owner == ev.User || - !ev.CanInteract || - !ev.CanAccess || - !TryComp(ev.User, out var body) || - !_body.TryGetBodyOrganEntityComps((ev.User, body), out var stomachs)) - return; - - // have to kill mouse before eating it - if (_mobState.IsAlive(entity) && entity.Comp.RequireDead) - return; - - // only give moths eat verb for clothes since it would just fail otherwise - if (!IsDigestibleBy(entity, entity.Comp, stomachs)) - return; - - var user = ev.User; - AlternativeVerb verb = new() - { - Act = () => - { - TryFeed(user, user, entity, entity.Comp); - }, - Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/cutlery.svg.192dpi.png")), - Text = Loc.GetString("food-system-verb-eat"), - Priority = -1 - }; - - ev.Verbs.Add(verb); - } - - /// - /// Returns true if the food item can be digested by the user. - /// - public bool IsDigestibleBy(EntityUid uid, EntityUid food, FoodComponent? foodComp = null) - { - if (!Resolve(food, ref foodComp, false)) - return false; - - if (!_body.TryGetBodyOrganEntityComps(uid, out var stomachs)) - return false; - - return IsDigestibleBy(food, foodComp, stomachs); - } - - /// - /// Returns true if has a that whitelists - /// this (or if they even have enough stomachs in the first place). - /// - private bool IsDigestibleBy(EntityUid food, FoodComponent component, List> stomachs) - { - var digestible = true; - - // Does the mob have enough stomachs? - if (stomachs.Count < component.RequiredStomachs) - return false; - - // Run through the mobs' stomachs - foreach (var ent in stomachs) - { - // Find a stomach with a SpecialDigestible - if (ent.Comp1.SpecialDigestible == null) - continue; - // Check if the food is in the whitelist - if (_whitelistSystem.IsWhitelistPass(ent.Comp1.SpecialDigestible, food)) - return true; - - // If their diet is whitelist exclusive, then they cannot eat anything but what follows their whitelisted tags. Else, they can eat their tags AND human food. - if (ent.Comp1.IsSpecialDigestibleExclusive) - return false; - } - - if (component.RequiresSpecialDigestion) - return false; - - return digestible; - } - - private bool TryGetRequiredUtensils(EntityUid user, FoodComponent component, - out List utensils, HandsComponent? hands = null) - { - utensils = new List(); - - if (component.Utensil == UtensilType.None) - return true; - - if (!Resolve(user, ref hands, false)) - return true; //mice - - var usedTypes = UtensilType.None; - - foreach (var item in _hands.EnumerateHeld((user, hands))) - { - // Is utensil? - if (!TryComp(item, out var utensil)) - continue; - - if ((utensil.Types & component.Utensil) != 0 && // Acceptable type? - (usedTypes & utensil.Types) != utensil.Types) // Type is not used already? (removes usage of identical utensils) - { - // Add to used list - usedTypes |= utensil.Types; - utensils.Add(item); - } - } - - // If "required" field is set, try to block eating without proper utensils used - if (component.UtensilRequired && (usedTypes & component.Utensil) != component.Utensil) - { - _popup.PopupClient(Loc.GetString("food-you-need-to-hold-utensil", ("utensil", component.Utensil ^ usedTypes)), user, user); - return false; - } - - return true; - } - - /// - /// Block ingestion attempts based on the equipped mask or head-wear - /// - private void OnInventoryIngestAttempt(Entity entity, ref IngestionAttemptEvent args) + private void OnFood(Entity food, ref EdibleEvent args) { if (args.Cancelled) return; - IngestionBlockerComponent? blocker; + if (args.Cancelled || args.Solution != null) + return; - if (_inventory.TryGetSlotEntity(entity.Owner, "mask", out var maskUid) && - TryComp(maskUid, out blocker) && - blocker.Enabled) + if (food.Comp.UtensilRequired && !_ingestion.HasRequiredUtensils(args.User, food.Comp.Utensil)) { - args.Blocker = maskUid; - args.Cancel(); + args.Cancelled = true; return; } - if (_inventory.TryGetSlotEntity(entity.Owner, "head", out var headUid) && - TryComp(headUid, out blocker) && - blocker.Enabled) - { - args.Blocker = headUid; - args.Cancel(); - } + // Check this last + _solutionContainer.TryGetSolution(food.Owner, food.Comp.Solution, out args.Solution); + args.Time += TimeSpan.FromSeconds(food.Comp.Delay); } - - /// - /// Check whether the target's mouth is blocked by equipment (masks or head-wear). - /// - /// The target whose equipment is checked - /// Optional entity that will receive an informative pop-up identifying the blocking - /// piece of equipment. - /// - public bool IsMouthBlocked(EntityUid uid, EntityUid? popupUid = null) + private void OnGetUtensils(Entity entity, ref GetUtensilsEvent args) { - var attempt = new IngestionAttemptEvent(); - RaiseLocalEvent(uid, attempt, false); - if (attempt.Cancelled && attempt.Blocker != null && popupUid != null) - { - _popup.PopupClient(Loc.GetString("food-system-remove-mask", ("entity", attempt.Blocker.Value)), - uid, popupUid.Value); - } + if (entity.Comp.Utensil == UtensilType.None) + return; - return attempt.Cancelled; + if (entity.Comp.UtensilRequired) + args.AddRequiredTypes(entity.Comp.Utensil); + else + args.Types |= entity.Comp.Utensil; } - /// - /// Get the number of bites this food has left, based on how much food solution there is and how much of it to eat per bite. - /// - public int GetUsesRemaining(EntityUid uid, FoodComponent? comp = null) + // TODO: When DrinkComponent and FoodComponent are properly obseleted, make the IsDigestionBools in IngestionSystem private again. + private void OnIsFoodDigestible(Entity ent, ref IsDigestibleEvent args) { - if (!Resolve(uid, ref comp)) - return 0; + if (ent.Comp.RequireDead && _mobState.IsAlive(ent)) + return; - if (!_solutionContainer.TryGetSolution(uid, comp.Solution, out _, out var solution) || solution.Volume == 0) - return 0; + args.AddDigestible(ent.Comp.RequiresSpecialDigestion); + } - // eat all in 1 go, so non empty is 1 bite - if (comp.TransferAmount == null) - return 1; + private void OnGetEdibleType(Entity ent, ref GetEdibleTypeEvent args) + { + if (args.Type != null) + return; - return Math.Max(1, (int) Math.Ceiling((solution.Volume / (FixedPoint2) comp.TransferAmount).Float())); + args.SetPrototype(IngestionSystem.Food); + } + + private void OnBeforeFullySliced(Entity food, ref BeforeFullySlicedEvent args) + { + if (food.Comp.Trash.Count == 0) + return; + + var position = _transform.GetMapCoordinates(food); + var trashes = food.Comp.Trash; + var tryPickup = _hands.IsHolding(args.User, food, out _); + + foreach (var trash in trashes) + { + var spawnedTrash = EntityManager.PredictedSpawn(trash, position); + + // If the user is holding the item + if (tryPickup) + { + // Put the trash in the user's hand + _hands.TryPickupAnyHand(args.User, spawnedTrash); + } + } } } diff --git a/Content.Shared/Nutrition/EntitySystems/IngestionBlockerSystem.cs b/Content.Shared/Nutrition/EntitySystems/IngestionBlockerSystem.cs deleted file mode 100644 index f9cd233948..0000000000 --- a/Content.Shared/Nutrition/EntitySystems/IngestionBlockerSystem.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Content.Shared.Clothing; -using Content.Shared.Nutrition.Components; - -namespace Content.Shared.Nutrition.EntitySystems; - -public sealed class IngestionBlockerSystem : EntitySystem -{ - public override void Initialize() - { - base.Initialize(); - - SubscribeLocalEvent(OnBlockerMaskToggled); - } - - private void OnBlockerMaskToggled(Entity ent, ref ItemMaskToggledEvent args) - { - ent.Comp.Enabled = !args.Mask.Comp.IsToggled; - } -} diff --git a/Content.Shared/Nutrition/EntitySystems/IngestionSystem.API.cs b/Content.Shared/Nutrition/EntitySystems/IngestionSystem.API.cs new file mode 100644 index 0000000000..3a8ef333d7 --- /dev/null +++ b/Content.Shared/Nutrition/EntitySystems/IngestionSystem.API.cs @@ -0,0 +1,430 @@ +using System.Diagnostics.CodeAnalysis; +using Content.Shared.Chemistry.Components; +using Content.Shared.Chemistry.Components.SolutionManager; +using Content.Shared.Chemistry.Reagent; +using Content.Shared.EntityEffects.Effects; +using Content.Shared.FixedPoint; +using Content.Shared.Inventory; +using Content.Shared.Nutrition.Components; +using Content.Shared.Nutrition.Prototypes; +using Content.Shared.Verbs; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Nutrition.EntitySystems; + +/// +/// Public API for Ingestion System so you can build your own form of ingestion system. +/// +public sealed partial class IngestionSystem +{ + // List of prototypes that other components or systems might want. + public static readonly ProtoId Food = "Food"; + public static readonly ProtoId Drink = "Drink"; + + public const float MaxFeedDistance = 1.0f; // We should really have generic interaction ranges like short, medium, long and use those instead... + // BodySystem has no way of telling us where the mouth is so we're making some assumptions. + public const SlotFlags DefaultFlags = SlotFlags.HEAD | SlotFlags.MASK; + + #region Ingestion + + /// + /// An entity is trying to ingest another entity in Space Station 14!!! + /// + /// The entity who is eating. + /// The entity that is trying to be ingested. + /// Returns true if we are now ingesting the item. + public bool TryIngest(EntityUid user, EntityUid ingested) + { + return TryIngest(user, user, ingested); + } + + /// + /// Overload of TryIngest for if an entity is trying to make another entity ingest an entity + /// The entity who is trying to make this happen. + /// The entity who is being made to ingest something. + /// The entity that is trying to be ingested. + public bool TryIngest(EntityUid user, EntityUid target, EntityUid ingested) + { + return AttemptIngest(user, target, ingested, true); + } + + /// + /// Checks if we can ingest a given entity without actually ingesting it. + /// + /// The entity doing the ingesting. + /// The ingested entity. + /// Returns true if it's possible for the entity to ingest this item. + public bool CanIngest(EntityUid user, EntityUid ingested) + { + return AttemptIngest(user, user, ingested, false); + } + + /// + /// Check whether we have an open pie-hole that's in range. + /// + /// The one performing the action + /// The target whose mouth is checked + /// + public bool HasMouthAvailable(EntityUid user, EntityUid target) + { + return HasMouthAvailable(user, target, DefaultFlags); + } + + /// + /// Overflow which takes custom flags for a mouth being blocked, in case the entity has a mouth not on the face. + public bool HasMouthAvailable(EntityUid user, EntityUid target, SlotFlags flags) + { + if (!_transform.GetMapCoordinates(user).InRange(_transform.GetMapCoordinates(target), MaxFeedDistance)) + { + var message = Loc.GetString("interaction-system-user-interaction-cannot-reach"); + _popup.PopupClient(message, user, user); + return false; + } + + var attempt = new IngestionAttemptEvent(flags); + RaiseLocalEvent(target, ref attempt); + + if (!attempt.Cancelled) + return true; + + if (attempt.Blocker != null) + _popup.PopupClient(Loc.GetString("ingestion-remove-mask", ("entity", attempt.Blocker.Value)), target, user); + + return false; + } + + /// + /// The entity that is consuming + /// The entity that is being consumed + public bool CanConsume(EntityUid user, EntityUid ingested) + { + return CanConsume(user, user, ingested, out _, out _); + } + + /// + /// Checks if we can feed an edible solution from an entity to a target. + /// + /// The one doing the feeding + /// The one being fed. + /// The food item being eaten. + /// Returns true if the user can feed the target with the ingested entity + public bool CanConsume(EntityUid user, EntityUid target, EntityUid ingested) + { + return CanConsume(user, target, ingested, out _, out _); + } + + /// + /// The one doing the feeding + /// The one being fed. + /// The food item being eaten. + /// The solution we will be consuming from. + /// The time it takes us to eat this entity if any. + /// Returns true if the user can feed the target with the ingested entity and also returns a solution + public bool CanConsume(EntityUid user, + EntityUid target, + EntityUid ingested, + [NotNullWhen(true)] out Entity? solution, + out TimeSpan? time) + { + solution = null; + time = null; + + if (!HasMouthAvailable(user, target)) + return false; + + // If we don't have the tools to eat we can't eat. + return CanAccessSolution(ingested, user, out solution, out time); + } + + #endregion + + #region EdibleComponent + + public void SpawnTrash(Entity entity, EntityUid user) + { + if (entity.Comp.Trash.Count == 0) + return; + + var position = _transform.GetMapCoordinates(entity); + var trashes = entity.Comp.Trash; + var tryPickup = _hands.IsHolding(user, entity, out _); + + foreach (var trash in trashes) + { + var spawnedTrash = EntityManager.PredictedSpawn(trash, position); + + // If the user is holding the item + if (tryPickup) + { + // Put the trash in the user's hand + _hands.TryPickupAnyHand(user, spawnedTrash); + } + } + } + + public FixedPoint2 EdibleVolume(Entity entity) + { + if (!_solutionContainer.TryGetSolution(entity.Owner, entity.Comp.Solution, out _, out var solution)) + return FixedPoint2.Zero; + + return solution.Volume; + } + + public bool IsEmpty(Entity entity) + { + return EdibleVolume(entity) == FixedPoint2.Zero; + } + + /// + /// Gets the total metabolizable nutrition from an entity, checks first if we can metabolize it. + /// If we can't then it's not worth any nutrition. + /// + /// The consumed entity + /// The entity doing the consuming + /// The amount of nutrition the consumable is worth + public float TotalNutrition(Entity entity, EntityUid consumer) + { + if (!CanIngest(consumer, entity)) + return 0f; + + return TotalNutrition(entity); + } + + /// + /// Gets the total metabolizable nutrition from an entity, assumes we can eat and metabolize it. + /// + /// The consumed entity + /// The amount of nutrition the consumable is worth + public float TotalNutrition(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return 0f; + + if (!_solutionContainer.TryGetSolution(entity.Owner, entity.Comp.Solution, out _, out var solution)) + return 0f; + + var total = 0f; + foreach (var quantity in solution.Contents) + { + var reagent = _proto.Index(quantity.Reagent.Prototype); + if (reagent.Metabolisms == null) + continue; + + foreach (var entry in reagent.Metabolisms.Values) + { + foreach (var effect in entry.Effects) + { + // ignores any effect conditions, just cares about how much it can hydrate + if (effect is SatiateHunger hunger) + { + total += hunger.NutritionFactor * quantity.Quantity.Float(); + } + } + } + } + + return total; + } + + /// + /// Gets the total metabolizable hydration from an entity, checks first if we can metabolize it. + /// If we can't then it's not worth any hydration. + /// + /// The consumed entity + /// The entity doing the consuming + /// The amount of hydration the consumable is worth + public float TotalHydration(Entity entity, EntityUid consumer) + { + if (!CanIngest(consumer, entity)) + return 0f; + + return TotalNutrition(entity); + } + + /// + /// Gets the total metabolizable hydration from an entity, assumes we can eat and metabolize it. + /// + /// The consumed entity + /// The amount of hydration the consumable is worth + public float TotalHydration(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return 0f; + + if (!_solutionContainer.TryGetSolution(entity.Owner, entity.Comp.Solution, out _, out var solution)) + return 0f; + + var total = 0f; + foreach (var quantity in solution.Contents) + { + var reagent = _proto.Index(quantity.Reagent.Prototype); + if (reagent.Metabolisms == null) + continue; + + foreach (var entry in reagent.Metabolisms.Values) + { + foreach (var effect in entry.Effects) + { + // ignores any effect conditions, just cares about how much it can hydrate + if (effect is SatiateThirst thirst) + { + total += thirst.HydrationFactor * quantity.Quantity.Float(); + } + } + } + } + + return total; + } + + #endregion + + #region Solutions + + /// + /// Checks if the item is currently edible. + /// + /// Entity being ingested + /// The entity trying to make the ingestion happening, not necessarily the one eating + /// Solution we're returning + /// The time it takes us to eat this entity + public bool CanAccessSolution(Entity ingested, + EntityUid user, + [NotNullWhen(true)] out Entity? solution, + out TimeSpan? time) + { + solution = null; + time = null; + + if (!Resolve(ingested, ref ingested.Comp)) + { + _popup.PopupClient(Loc.GetString("ingestion-try-use-is-empty", ("entity", ingested)), ingested, user); + return false; + } + + var ev = new EdibleEvent(user); + RaiseLocalEvent(ingested, ref ev); + + solution = ev.Solution; + time = ev.Time; + + return !ev.Cancelled && solution != null; + } + + /// + /// Estimate the number of bites this food has left, based on how much food solution there is and how much of it to eat per bite. + /// + public int GetUsesRemaining(EntityUid uid, string solutionName, FixedPoint2 splitVol) + { + if (!_solutionContainer.TryGetSolution(uid, solutionName, out _, out var solution) || solution.Volume == 0) + return 0; + + return Math.Max(1, (int) Math.Ceiling((solution.Volume / splitVol).Float())); + } + + #endregion + + #region Edible Types + + /// + /// Tries to get the ingestion verbs for a given user entity and ingestible entity + /// + /// The one getting the verbs who would be doing the eating. + /// Entity being ingested. + /// Edible prototype. + /// Verb we're returning. + /// Returns true if we generated a verb. + public bool TryGetIngestionVerb(EntityUid user, EntityUid ingested, [ForbidLiteral] ProtoId type, [NotNullWhen(true)] out AlternativeVerb? verb) + { + verb = null; + + // We want to see if we can ingest this item, but we don't actually want to ingest it. + if (!CanIngest(user, ingested)) + return false; + + var proto = _proto.Index(type); + + verb = new() + { + Act = () => + { + TryIngest(user, user, ingested); + }, + Icon = proto.VerbIcon, + Text = Loc.GetString(proto.VerbName), + Priority = 2 + }; + + return true; + } + + /// + /// Returns the most accurate edible prototype for an entity if one exists. + /// + /// entity who's edible prototype we want + /// The best matching prototype if one exists. + public ProtoId? GetEdibleType(Entity entity) + { + if (Resolve(entity, ref entity.Comp, false)) + return entity.Comp.Edible; + + var ev = new GetEdibleTypeEvent(); + RaiseLocalEvent(entity, ref ev); + + return ev.Type; + } + + public string GetEdibleNoun(Entity entity) + { + if (Resolve(entity, ref entity.Comp, false)) + return GetProtoVerb(entity.Comp.Edible); + + var ev = new GetEdibleTypeEvent(); + RaiseLocalEvent(entity, ref ev); + + if (ev.Type == null) + return Loc.GetString("edible-noun-edible"); + + return GetProtoNoun(ev.Type.Value); + } + + public string GetProtoNoun([ForbidLiteral] ProtoId proto) + { + var prototype = _proto.Index(proto); + + return GetProtoNoun(prototype); + } + + public string GetProtoNoun(EdiblePrototype proto) + { + return Loc.GetString(proto.Noun); + } + + public string GetEdibleVerb(Entity entity) + { + if (Resolve(entity, ref entity.Comp, false)) + return GetProtoVerb(entity.Comp.Edible); + + var ev = new GetEdibleTypeEvent(); + RaiseLocalEvent(entity, ref ev); + + if (ev.Type == null) + return Loc.GetString("edible-verb-edible"); + + return GetProtoVerb(ev.Type.Value); + } + + public string GetProtoVerb([ForbidLiteral] ProtoId proto) + { + var prototype = _proto.Index(proto); + + return GetProtoVerb(prototype); + } + + public string GetProtoVerb(EdiblePrototype proto) + { + return Loc.GetString(proto.Verb); + } + + #endregion +} diff --git a/Content.Shared/Nutrition/EntitySystems/IngestionSystem.Blockers.cs b/Content.Shared/Nutrition/EntitySystems/IngestionSystem.Blockers.cs new file mode 100644 index 0000000000..e1bd480bcc --- /dev/null +++ b/Content.Shared/Nutrition/EntitySystems/IngestionSystem.Blockers.cs @@ -0,0 +1,159 @@ +using System.Linq; +using Content.Shared.Chemistry.Components; +using Content.Shared.Clothing; +using Content.Shared.Containers.ItemSlots; +using Content.Shared.Fluids.Components; +using Content.Shared.Interaction.Components; +using Content.Shared.Inventory; +using Content.Shared.Nutrition.Components; +using Content.Shared.Storage; + +namespace Content.Shared.Nutrition.EntitySystems; + +public sealed partial class IngestionSystem +{ + [Dependency] private readonly OpenableSystem _openable = default!; + + public void InitializeBlockers() + { + SubscribeLocalEvent(OnUnremovableIngestion); + SubscribeLocalEvent(OnBlockerMaskToggled); + SubscribeLocalEvent(OnIngestionBlockerAttempt); + SubscribeLocalEvent>(OnIngestionBlockerAttempt); + + // Edible Event + SubscribeLocalEvent(OnEdible); + SubscribeLocalEvent(OnStorageEdible); + SubscribeLocalEvent(OnItemSlotsEdible); + SubscribeLocalEvent(OnOpenableEdible); + + // Digestion Events + SubscribeLocalEvent(OnEdibleIsDigestible); + SubscribeLocalEvent(OnDrainableIsDigestible); + SubscribeLocalEvent(OnPuddleIsDigestible); + + SubscribeLocalEvent(OnPillBeforeEaten); + } + + private void OnUnremovableIngestion(Entity entity, ref IngestibleEvent args) + { + // If we can't remove it we probably shouldn't be able to eat it. + // TODO: Separate glue and Unremovable component. + args.Cancelled = true; + } + + private void OnBlockerMaskToggled(Entity ent, ref ItemMaskToggledEvent args) + { + ent.Comp.Enabled = !args.Mask.Comp.IsToggled; + } + + private void OnIngestionBlockerAttempt(Entity entity, ref IngestionAttemptEvent args) + { + if (!args.Cancelled && entity.Comp.Enabled) + args.Cancelled = true; + } + + /// + /// Block ingestion attempts based on the equipped mask or head-wear + /// + private void OnIngestionBlockerAttempt(Entity entity, ref InventoryRelayedEvent args) + { + if (args.Args.Cancelled || !entity.Comp.Enabled) + return; + + args.Args.Cancelled = true; + args.Args.Blocker = entity; + } + + private void OnEdible(Entity entity, ref EdibleEvent args) + { + if (args.Cancelled || args.Solution != null) + return; + + if (entity.Comp.UtensilRequired && !HasRequiredUtensils(args.User, entity.Comp.Utensil)) + { + args.Cancelled = true; + return; + } + + // Check this last + if (!_solutionContainer.TryGetSolution(entity.Owner, entity.Comp.Solution, out args.Solution) || IsEmpty(entity) && !entity.Comp.DestroyOnEmpty) + { + args.Cancelled = true; + + _popup.PopupClient(Loc.GetString("ingestion-try-use-is-empty", ("entity", entity)), entity, args.User); + return; + } + + // Time is additive because I said so. + args.Time += entity.Comp.Delay; + } + + private void OnStorageEdible(Entity ent, ref EdibleEvent args) + { + if (args.Cancelled) + return; + + if (!ent.Comp.Container.ContainedEntities.Any()) + return; + + args.Cancelled = true; + + _popup.PopupClient(Loc.GetString("edible-has-used-storage", ("food", ent), ("verb", GetEdibleVerb(ent.Owner))), args.User, args.User); + } + + private void OnItemSlotsEdible(Entity ent, ref EdibleEvent args) + { + if (args.Cancelled) + return; + + if (!ent.Comp.Slots.Any(slot => slot.Value.HasItem)) + return; + + args.Cancelled = true; + + _popup.PopupClient(Loc.GetString("edible-has-used-storage", ("food", ent), ("verb", GetEdibleVerb(ent.Owner))), args.User, args.User); + } + + private void OnOpenableEdible(Entity ent, ref EdibleEvent args) + { + if (_openable.IsClosed(ent, args.User, ent.Comp)) + args.Cancelled = true; + } + + private void OnEdibleIsDigestible(Entity ent, ref IsDigestibleEvent args) + { + if (ent.Comp.RequireDead && _mobState.IsAlive(ent)) + return; + + args.AddDigestible(ent.Comp.RequiresSpecialDigestion); + } + + /// + /// Both of these assume that having this component means there's nothing stopping you from slurping up + /// pure reagent juice with absolutely nothing to stop you. + /// + private void OnDrainableIsDigestible(Entity ent, ref IsDigestibleEvent args) + { + args.UniversalDigestion(); + } + + private void OnPuddleIsDigestible(Entity ent, ref IsDigestibleEvent args) + { + args.UniversalDigestion(); + } + + /// + /// I mean you have to eat the *whole* pill no? + /// + private void OnPillBeforeEaten(Entity ent, ref BeforeIngestedEvent args) + { + if (args.Cancelled || args.Solution is not { } sol) + return; + + if (args.TryNewMinimum(sol.Volume)) + return; + + args.Cancelled = true; + } +} diff --git a/Content.Shared/Nutrition/EntitySystems/IngestionSystem.Utensils.cs b/Content.Shared/Nutrition/EntitySystems/IngestionSystem.Utensils.cs new file mode 100644 index 0000000000..670fdc8dfb --- /dev/null +++ b/Content.Shared/Nutrition/EntitySystems/IngestionSystem.Utensils.cs @@ -0,0 +1,157 @@ +using Content.Shared.Containers.ItemSlots; +using Content.Shared.Hands.Components; +using Content.Shared.Interaction; +using Content.Shared.Nutrition.Components; +using Content.Shared.Random.Helpers; +using Content.Shared.Tools.EntitySystems; +using Robust.Shared.Audio; +using Robust.Shared.Random; +using Robust.Shared.Timing; + +namespace Content.Shared.Nutrition.EntitySystems; + +public sealed partial class IngestionSystem +{ + [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + private EntityQuery _utensilsQuery; + + public void InitializeUtensils() + { + SubscribeLocalEvent(OnAfterInteract, after: new[] { typeof(ToolOpenableSystem) }); + + SubscribeLocalEvent(OnGetEdibleUtensils); + + _utensilsQuery = GetEntityQuery(); + } + + /// + /// Clicked with utensil + /// + private void OnAfterInteract(Entity entity, ref AfterInteractEvent ev) + { + if (ev.Handled || ev.Target == null || !ev.CanReach) + return; + + ev.Handled = TryUseUtensil(ev.User, ev.Target.Value, entity); + } + + public bool TryUseUtensil(EntityUid user, EntityUid target, Entity utensil) + { + var ev = new GetUtensilsEvent(); + RaiseLocalEvent(target, ref ev); + + //Prevents food usage with a wrong utensil + if ((ev.Types & utensil.Comp.Types) == 0) + { + _popup.PopupClient(Loc.GetString("ingestion-try-use-wrong-utensil", ("verb", GetEdibleVerb(target)),("food", target), ("utensil", utensil.Owner)), user, user); + return true; + } + + if (!_interactionSystem.InRangeUnobstructed(user, target, popup: true)) + return true; + + return TryIngest(user, user, target); + } + + /// + /// Attempt to break the utensil after interaction. + /// + /// Utensil. + /// User of the utensil. + public void TryBreak(Entity entity, EntityUid userUid) + { + if (!Resolve(entity, ref entity.Comp)) + return; + + // TODO: Once we have predicted randomness delete this for something sane... + var seed = SharedRandomExtensions.HashCodeCombine(new() {(int)_timing.CurTick.Value, GetNetEntity(entity).Id, GetNetEntity(userUid).Id }); + var rand = new System.Random(seed); + + if (!rand.Prob(entity.Comp.BreakChance)) + return; + + _audio.PlayPredicted(entity.Comp.BreakSound, userUid, userUid, AudioParams.Default.WithVolume(-2f)); + // Not prediced because no random predicted + PredictedDel(entity.Owner); + } + + /// + /// Checks if we have the utensils required to eat a certain food item. + /// + /// Entity that is trying to eat. + /// The types of utensils we need. + /// The utensils needed to eat the food item. + /// True if we are able to eat the item. + public bool TryGetUtensils(Entity entity, EntityUid food, out List utensils) + { + var ev = new GetUtensilsEvent(); + RaiseLocalEvent(food, ref ev); + + return TryGetUtensils(entity, ev.Types, ev.RequiredTypes, out utensils); + } + + public bool TryGetUtensils(Entity entity, UtensilType types, UtensilType requiredTypes, out List utensils) + { + utensils = new List(); + + var required = requiredTypes != UtensilType.None; + + // Why are we even here? Just to suffer? + if (types == UtensilType.None) + return true; + + // If you don't have hands you can eat anything I guess. + if (!Resolve(entity, ref entity.Comp, false)) // You aren't allowed to eat with your hands in this hellish dystopia. + return true; + + var usedTypes = UtensilType.None; + + foreach (var item in _hands.EnumerateHeld(entity)) + { + // Is utensil? + if (!_utensilsQuery.TryComp(item, out var utensil)) + continue; + + // Do we have a new and unused utensil type? + if ((utensil.Types & types) == 0 || (usedTypes & utensil.Types) == utensil.Types) + continue; + + // Add to used list + usedTypes |= utensil.Types; + utensils.Add(item); + } + + // If "required" field is set, try to block eating without proper utensils used + if (!required || (usedTypes & requiredTypes) == requiredTypes) + return true; + + _popup.PopupClient(Loc.GetString("ingestion-you-need-to-hold-utensil", ("utensil", requiredTypes ^ usedTypes)), entity, entity); + return false; + + } + + /// + /// Checks if you have the required utensils based on a list of types. + /// Note it is assumed if you're calling this method that you need utensils. + /// + /// The entity doing the action who has the utensils. + /// The types of utensils we need. + /// Returns true if we have the utensils we need. + public bool HasRequiredUtensils(EntityUid entity, UtensilType types) + { + return TryGetUtensils(entity, types, types, out _); + } + + private void OnGetEdibleUtensils(Entity entity, ref GetUtensilsEvent args) + { + if (entity.Comp.Utensil == UtensilType.None) + return; + + if (entity.Comp.UtensilRequired) + args.AddRequiredTypes(entity.Comp.Utensil); + else + args.Types |= entity.Comp.Utensil; + } +} diff --git a/Content.Shared/Nutrition/EntitySystems/IngestionSystem.cs b/Content.Shared/Nutrition/EntitySystems/IngestionSystem.cs new file mode 100644 index 0000000000..cdd366ba50 --- /dev/null +++ b/Content.Shared/Nutrition/EntitySystems/IngestionSystem.cs @@ -0,0 +1,531 @@ +using Content.Shared.Administration.Logs; +using Content.Shared.Body.Components; +using Content.Shared.Body.Organ; +using Content.Shared.Body.Systems; +using Content.Shared.Chemistry; +using Content.Shared.Chemistry.Components; +using Content.Shared.Chemistry.EntitySystems; +using Content.Shared.Database; +using Content.Shared.Destructible; +using Content.Shared.DoAfter; +using Content.Shared.FixedPoint; +using Content.Shared.Forensics; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.IdentityManagement; +using Content.Shared.Interaction; +using Content.Shared.Interaction.Events; +using Content.Shared.Inventory; +using Content.Shared.Mobs.Systems; +using Content.Shared.Nutrition.Components; +using Content.Shared.Popups; +using Content.Shared.Tools.EntitySystems; +using Content.Shared.Verbs; +using Content.Shared.Whitelist; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Nutrition.EntitySystems; + +/// +/// I was warned about puddle system, I knew the risks with body system, but food and drink system? +/// Food and Drink system was a sleeping titan, and I walked directly into it's gaping maw. +/// Between copy-pasted code, strange reliance on systems, being a pillar of chemistry for some reason, +/// nothing could've prepared me for the horror that I had to endure. I saw the signs, comments of those who +/// turned back, code that was made to be "just good enough" the fact that I got soaped by soap.yml, but I +/// ignored them and pressed on. +/// Let this remark be a reminder to those who come after, that I was here, and that I vanquished a great beast. +/// Let young little contributors rest easy at night not knowing the horrible system that once lived beneath the +/// bedrock of the codebase they now commit to. +/// +/// +/// This handles the ingestion of solutions and entities. +/// +public sealed partial class IngestionSystem : EntitySystem +{ + [Dependency] private readonly IPrototypeManager _proto = default!; + [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; + [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!; + [Dependency] private readonly FlavorProfileSystem _flavorProfile = default!; + [Dependency] private readonly MobStateSystem _mobState = default!; + [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + [Dependency] private readonly SharedSolutionContainerSystem _solutionContainer = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + // Body Component Dependencies + [Dependency] private readonly SharedBodySystem _body = default!; + [Dependency] private readonly ReactiveSystem _reaction = default!; + [Dependency] private readonly StomachSystem _stomach = default!; + + /// + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnEdibleInit); + + // Interactions + SubscribeLocalEvent(OnUseEdibleInHand, after: new[] { typeof(OpenableSystem), typeof(InventorySystem) }); + SubscribeLocalEvent(OnEdibleInteract, after: new[] { typeof(ToolOpenableSystem) }); + + // Generic Eating Handlers + SubscribeLocalEvent(OnBeforeIngested); + SubscribeLocalEvent(OnEdibleIngested); + SubscribeLocalEvent(OnFullyEaten); + + // Body Component eating handler + SubscribeLocalEvent(OnTryIngest); + SubscribeLocalEvent(OnEatingDoAfter); + + // Verbs + SubscribeLocalEvent>(AddEdibleVerbs); + SubscribeLocalEvent(OnSolutionContainerChanged); + + // Misc + SubscribeLocalEvent(OnAttemptShake); + SubscribeLocalEvent(OnBeforeFullySliced); + + InitializeBlockers(); + InitializeUtensils(); + } + + /// + /// Eat or drink an item + /// + private void OnUseEdibleInHand(Entity entity, ref UseInHandEvent ev) + { + if (ev.Handled) + return; + + ev.Handled = TryIngest(ev.User, entity); + } + + /// + /// Feed someone else + /// + private void OnEdibleInteract(Entity entity, ref AfterInteractEvent args) + { + if (args.Handled || args.Target == null || !args.CanReach) + return; + + args.Handled = TryIngest(args.User, args.Target.Value, entity); + } + + /// Raises events to see if it's possible to ingest + /// The entity who is trying to make this happen. + /// The entity who is being made to ingest something. + /// The entity that is trying to be ingested. + /// Bool that determines whethere this is a Try or a Can effectively. + /// When set to true, it tries to ingest, when false it checks if we can. + /// Returns true if we can ingest the item. + private bool AttemptIngest(EntityUid user, EntityUid target, EntityUid ingested, bool ingest) + { + var eatEv = new IngestibleEvent(); + RaiseLocalEvent(ingested, ref eatEv); + + if (eatEv.Cancelled) + return false; + + var ingestionEv = new AttemptIngestEvent(user, ingested, ingest); + RaiseLocalEvent(target, ref ingestionEv); + + return ingestionEv.Handled; + } + + private void OnEdibleInit(Entity entity, ref ComponentInit args) + { + // TODO: When Food and Drink component are kill make sure to nuke both TryComps and just have it update appearance... + // Beakers, Soap and other items have drainable, and we should be able to eat that solution... + // If I could make drainable properly support sound effects and such I'd just have it use TryIngest itself + // Does this exist just to make tests fail? That way you have the proper yaml??? + if (TryComp(entity, out var existingDrainable)) + entity.Comp.Solution = existingDrainable.Solution; + + UpdateAppearance(entity); + + if (TryComp(entity, out RefillableSolutionComponent? refillComp)) + refillComp.Solution = entity.Comp.Solution; + } + + #region Appearance System + + public void UpdateAppearance(Entity entity) + { + if (!Resolve(entity, ref entity.Comp2, false)) + return; + + var drainAvailable = EdibleVolume(entity); + _appearance.SetData(entity, FoodVisuals.Visual, drainAvailable.Float(), entity.Comp2); + } + + private void OnSolutionContainerChanged(Entity entity, ref SolutionContainerChangedEvent args) + { + UpdateAppearance(entity); + } + + #endregion + + #region BodySystem + + // TODO: The IsDigestibleBy bools should be API but they're too specific to the BodySystem to be API. Requires BodySystem rework. + /// + /// Generic method which takes a list of stomachs, and checks if a given food item passes any stomach's whitelist + /// in a given list of stomachs. + /// + /// Entity being eaten + /// Stomachs available to digest + public bool IsDigestibleBy(EntityUid food, List> stomachs) + { + var ev = new IsDigestibleEvent(); + RaiseLocalEvent(food, ref ev); + + if (!ev.Digestible) + return false; + + if (ev.Universal) + return true; + + if (ev.SpecialDigestion) + { + foreach (var ent in stomachs) + { + // We need one stomach that can digest our special food. + if (_whitelistSystem.IsWhitelistPass(ent.Comp1.SpecialDigestible, food)) + return true; + } + } + else + { + foreach (var ent in stomachs) + { + // We need one stomach that can digest normal food. + if (ent.Comp1.SpecialDigestible == null + || !ent.Comp1.IsSpecialDigestibleExclusive + || _whitelistSystem.IsWhitelistPass(ent.Comp1.SpecialDigestible, food)) + return true; + } + } + + // If we didn't find a stomach that can digest our food then it doesn't exist. + return false; + } + + /// + /// Generic method which takes a single stomach into account, and checks if a given food item passes a stomach whitelist. + /// + /// Entity being eaten + /// Stomachs that is attempting to digest. + public bool IsDigestibleBy(EntityUid food, Entity stomach) + { + var ev = new IsDigestibleEvent(); + RaiseLocalEvent(food, ref ev); + + if (!ev.Digestible) + return false; + + if (ev.Universal) + return true; + + if (ev.SpecialDigestion) + return _whitelistSystem.IsWhitelistPass(stomach.Comp1.SpecialDigestible, food); + + if (stomach.Comp1.SpecialDigestible == null || !stomach.Comp1.IsSpecialDigestibleExclusive || _whitelistSystem.IsWhitelistPass(stomach.Comp1.SpecialDigestible, food)) + return true; + + return false; + } + + private void OnTryIngest(Entity entity, ref AttemptIngestEvent args) + { + var food = args.Ingested; + var forceFed = args.User != entity.Owner; + + if (!_body.TryGetBodyOrganEntityComps(entity!, out var stomachs)) + return; + + // Can we digest the specific item we're trying to eat? + if (!IsDigestibleBy(args.Ingested, stomachs)) + { + if (forceFed) + { + _popup.PopupClient(Loc.GetString("ingestion-cant-digest-other", ("target", entity), ("entity", food)), entity, args.User); + } + else + _popup.PopupClient(Loc.GetString("ingestion-cant-digest", ("entity", food)), entity, entity); + + return; + } + + + // Exit early if we're just trying to get verbs + if (!args.Ingest) + { + args.Handled = true; + return; + } + + // Check if despite being able to digest the item something is blocking us from eating. + if (!CanConsume(args.User, entity, args.Ingested, out var solution, out var time)) + return; + + if (!_doAfter.TryStartDoAfter(GetEdibleDoAfterArgs(args.User, entity, food, time ?? TimeSpan.Zero))) + return; + + args.Handled = true; + var foodSolution = solution.Value.Comp.Solution; + + if (forceFed) + { + var userName = Identity.Entity(args.User, EntityManager); + _popup.PopupEntity(Loc.GetString("edible-force-feed", ("user", userName), ("verb", GetEdibleVerb(food))), args.User, entity); + + // logging + _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{ToPrettyString(args.User):user} is forcing {ToPrettyString(entity):target} to eat {ToPrettyString(food):food} {SharedSolutionContainerSystem.ToPrettyString(foodSolution)}"); + } + else + { + // log voluntary eating + _adminLogger.Add(LogType.Ingestion, LogImpact.Low, $"{ToPrettyString(entity):target} is eating {ToPrettyString(food):food} {SharedSolutionContainerSystem.ToPrettyString(foodSolution)}"); + } + } + + private void OnEatingDoAfter(Entity entity, ref EatingDoAfterEvent args) + { + if (args.Cancelled || args.Handled || entity.Comp.Deleted || args.Target == null) + return; + + var food = args.Target.Value; + + var blockerEv = new IngestibleEvent(); + RaiseLocalEvent(food, ref blockerEv); + + if (blockerEv.Cancelled) + return; + + if (!CanConsume(args.User, entity, food, out var solution, out _)) + return; + + if (!_body.TryGetBodyOrganEntityComps(entity!, out var stomachs)) + return; + + var forceFed = args.User != entity.Owner; + + var highestAvailable = FixedPoint2.Zero; + Entity? stomachToUse = null; + foreach (var ent in stomachs) + { + var owner = ent.Owner; + if (!_solutionContainer.ResolveSolution(owner, StomachSystem.DefaultSolutionName, ref ent.Comp1.Solution, out var stomachSol)) + continue; + + if (stomachSol.AvailableVolume <= highestAvailable) + continue; + + if (!IsDigestibleBy(food, ent)) + continue; + + stomachToUse = ent; + highestAvailable = stomachSol.AvailableVolume; + } + + // All stomachs are full or we have no stomachs + if (stomachToUse == null) + { + // Very long + _popup.PopupClient(Loc.GetString("ingestion-you-cannot-ingest-any-more", ("verb", GetEdibleVerb(food))), entity, entity); + if (!forceFed) + return; + + _popup.PopupClient(Loc.GetString("ingestion-other-cannot-ingest-any-more", ("target", entity), ("verb", GetEdibleVerb(food))), args.Target.Value, args.User); + return; + } + + var beforeEv = new BeforeIngestedEvent(FixedPoint2.Zero, highestAvailable, solution.Value.Comp.Solution); + RaiseLocalEvent(food, ref beforeEv); + RaiseLocalEvent(entity, ref beforeEv); + + if (beforeEv.Cancelled || beforeEv.Min > beforeEv.Max) + { + // Very long x2 + _popup.PopupClient(Loc.GetString("ingestion-you-cannot-ingest-any-more", ("verb", GetEdibleVerb(food))), entity, entity); + if (!forceFed) + return; + + _popup.PopupClient(Loc.GetString("ingestion-other-cannot-ingest-any-more", ("target", entity), ("verb", GetEdibleVerb(food))), args.Target.Value, args.User); + return; + } + + var transfer = FixedPoint2.Clamp(beforeEv.Transfer, beforeEv.Min, beforeEv.Max); + + var split = _solutionContainer.SplitSolution(solution.Value, transfer); + + var ingestEv = new IngestingEvent(food, split, forceFed); + RaiseLocalEvent(entity, ref ingestEv); + + _reaction.DoEntityReaction(entity, split, ReactionMethod.Ingestion); + + // Everything is good to go item has been successfuly eaten + var afterEv = new IngestedEvent(args.User, entity, split, forceFed); + RaiseLocalEvent(food, ref afterEv); + + if (afterEv.Refresh) + _solutionContainer.TryAddSolution(solution.Value, split); + + _stomach.TryTransferSolution(stomachToUse.Value.Owner, split, stomachToUse); + + if (!afterEv.Destroy) + { + args.Repeat = afterEv.Repeat; + return; + } + + var ev = new DestructionAttemptEvent(); + RaiseLocalEvent(food, ev); + if (ev.Cancelled) + return; + + // Tell the food that it's time to die. + var finishedEv = new FullyEatenEvent(args.User); + RaiseLocalEvent(food, ref finishedEv); + + var eventArgs = new DestructionEventArgs(); + RaiseLocalEvent(food, eventArgs); + + PredictedDel(food); + + // Don't try to repeat if its being deleted + args.Repeat = false; + } + + /// + /// Gets the DoAfterArgs for the specific event + /// + /// Entity that is doing the action. + /// Entity that is eating. + /// Food entity we're trying to eat. + /// The time delay for our DoAfter + /// Returns true if it was able to successfully start the DoAfter + private DoAfterArgs GetEdibleDoAfterArgs(EntityUid user, EntityUid target, EntityUid food, TimeSpan delay = default) + { + var forceFeed = user != target; + + var doAfterArgs = new DoAfterArgs(EntityManager, user, delay, new EatingDoAfterEvent(), target, food) + { + BreakOnHandChange = false, + BreakOnMove = forceFeed, + BreakOnDamage = true, + MovementThreshold = 0.01f, + DistanceThreshold = MaxFeedDistance, + // do-after will stop if item is dropped when trying to feed someone else + // or if the item started out in the user's own hands + NeedHand = forceFeed || _hands.IsHolding(user, food), + }; + + return doAfterArgs; + } + + #endregion + + private void OnBeforeIngested(Entity food, ref BeforeIngestedEvent args) + { + if (args.Cancelled || args.Solution is not { } solution) + return; + + // Set it to transfer amount if it exists, otherwise eat the whole volume if possible. + args.Transfer = food.Comp.TransferAmount ?? solution.Volume; + } + + private void OnEdibleIngested(Entity entity, ref IngestedEvent args) + { + // This is a lot but there wasn't really a way to separate this from the EdibleComponent otherwise I would've moved it. + + if (args.Handled) + return; + + args.Handled = true; + + var edible = _proto.Index(entity.Comp.Edible); + + _audio.PlayPredicted(edible.UseSound, args.Target, args.User); + + var flavors = _flavorProfile.GetLocalizedFlavorsMessage(entity.Owner, args.Target, args.Split); + + if (args.ForceFed) + { + var targetName = Identity.Entity(args.Target, EntityManager); + var userName = Identity.Entity(args.User, EntityManager); + _popup.PopupEntity(Loc.GetString("edible-force-feed-success", ("user", userName), ("verb", edible.Verb), ("flavors", flavors)), entity, entity); + + _popup.PopupClient(Loc.GetString("edible-force-feed-success-user", ("target", targetName), ("verb", edible.Verb)), args.User, args.User); + + // log successful forced feeding + _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{ToPrettyString(entity):user} forced {ToPrettyString(args.User):target} to eat {ToPrettyString(entity):food}"); + } + else + { + _popup.PopupClient(Loc.GetString(edible.Message, ("food", entity.Owner), ("flavors", flavors)), args.User, args.User); + + // log successful voluntary eating + _adminLogger.Add(LogType.Ingestion, LogImpact.Low, $"{ToPrettyString(args.User):target} ate {ToPrettyString(entity):food}"); + } + + // BREAK OUR UTENSILS + if (TryGetUtensils(args.User, entity, out var utensils)) + { + foreach (var utensil in utensils) + { + TryBreak(utensil, args.User); + } + } + + // This also prevents us from repeating if it's empty + if (!IsEmpty(entity)) + { + // Leave some of the consumer's DNA on the consumed item... + var ev = new TransferDnaEvent + { + Donor = args.Target, + Recipient = entity, + CanDnaBeCleaned = false, + }; + RaiseLocalEvent(args.Target, ref ev); + + args.Repeat = !args.ForceFed; + return; + } + + args.Destroy = entity.Comp.DestroyOnEmpty; + } + + private void OnFullyEaten(Entity entity, ref FullyEatenEvent args) + { + SpawnTrash(entity, args.User); + } + + private void OnBeforeFullySliced(Entity entity, ref BeforeFullySlicedEvent args) + { + SpawnTrash(entity, args.User); + } + + private void AddEdibleVerbs(Entity entity, ref GetVerbsEvent args) + { + var user = args.User; + + if (entity.Owner == user || !args.CanInteract || !args.CanAccess) + return; + + if (!TryGetIngestionVerb(user, entity, entity.Comp.Edible, out var verb)) + return; + + args.Verbs.Add(verb); + } + + private void OnAttemptShake(Entity entity, ref AttemptShakeEvent args) + { + if (IsEmpty(entity)) + args.Cancelled = true; + } +} diff --git a/Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs b/Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs index 2f276fa93d..ab462557d9 100644 --- a/Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs +++ b/Content.Shared/Nutrition/EntitySystems/OpenableSystem.cs @@ -121,11 +121,8 @@ public sealed partial class OpenableSystem : EntitySystem private void OnTransferAttempt(Entity ent, ref SolutionTransferAttemptEvent args) { - if (!ent.Comp.Opened) - { - // message says its just for drinks, shouldn't matter since you typically dont have a food that is openable and can be poured out - args.Cancel(Loc.GetString("drink-component-try-use-drink-not-open", ("owner", ent.Owner))); - } + if (ent.Comp.Opened) + args.Cancel(Loc.GetString(ent.Comp.ClosedPopup, ("owner", ent.Owner))); } private void OnAttemptShake(Entity entity, ref AttemptShakeEvent args) diff --git a/Content.Shared/Nutrition/EntitySystems/SharedDrinkSystem.cs b/Content.Shared/Nutrition/EntitySystems/SharedDrinkSystem.cs index 66e4834d0d..303d94d55f 100644 --- a/Content.Shared/Nutrition/EntitySystems/SharedDrinkSystem.cs +++ b/Content.Shared/Nutrition/EntitySystems/SharedDrinkSystem.cs @@ -1,34 +1,28 @@ using Content.Shared.Administration.Logs; -using Content.Shared.Body.Components; -using Content.Shared.Body.Systems; -using Content.Shared.Chemistry.Components.SolutionManager; using Content.Shared.Chemistry.EntitySystems; using Content.Shared.Database; -using Content.Shared.DoAfter; -using Content.Shared.Examine; using Content.Shared.FixedPoint; -using Content.Shared.Hands.EntitySystems; +using Content.Shared.Forensics; using Content.Shared.IdentityManagement; using Content.Shared.Interaction; -using Content.Shared.Mobs.Systems; +using Content.Shared.Interaction.Events; +using Content.Shared.Inventory; using Content.Shared.Nutrition.Components; using Content.Shared.Popups; using Content.Shared.Verbs; -using Robust.Shared.Utility; +using Robust.Shared.Audio; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Player; namespace Content.Shared.Nutrition.EntitySystems; +[Obsolete("Migration to Content.Shared.Nutrition.EntitySystems.IngestionSystem is required")] public abstract partial class SharedDrinkSystem : EntitySystem { + [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; - [Dependency] private readonly SharedBodySystem _body = default!; - [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; [Dependency] private readonly FlavorProfileSystem _flavorProfile = default!; - [Dependency] private readonly FoodSystem _food = default!; - [Dependency] private readonly SharedHandsSystem _hands = default!; - [Dependency] private readonly SharedInteractionSystem _interaction = default!; - [Dependency] private readonly MobStateSystem _mobState = default!; - [Dependency] private readonly OpenableSystem _openable = default!; + [Dependency] private readonly IngestionSystem _ingestion = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; [Dependency] private readonly SharedSolutionContainerSystem _solutionContainer = default!; @@ -36,8 +30,21 @@ public abstract partial class SharedDrinkSystem : EntitySystem { base.Initialize(); + SubscribeLocalEvent(OnUseDrinkInHand, after: new[] { typeof(OpenableSystem), typeof(InventorySystem) }); + SubscribeLocalEvent(OnUseDrink); + SubscribeLocalEvent(OnAttemptShake); + SubscribeLocalEvent>(AddDrinkVerb); + + SubscribeLocalEvent(OnBeforeDrinkEaten); + SubscribeLocalEvent(OnDrinkEaten); + + SubscribeLocalEvent(OnDrink); + + SubscribeLocalEvent(OnIsDigestible); + + SubscribeLocalEvent(OnGetEdibleType); } protected void OnAttemptShake(Entity entity, ref AttemptShakeEvent args) @@ -46,38 +53,6 @@ public abstract partial class SharedDrinkSystem : EntitySystem args.Cancelled = true; } - private void AddDrinkVerb(Entity entity, ref GetVerbsEvent ev) - { - if (entity.Owner == ev.User || - !ev.CanInteract || - !ev.CanAccess || - !TryComp(ev.User, out var body) || - !_body.TryGetBodyOrganEntityComps((ev.User, body), out var stomachs)) - return; - - // Make sure the solution exists - if (!_solutionContainer.TryGetSolution(entity.Owner, entity.Comp.Solution, out var solution)) - return; - - // no drinking from living drinks, have to kill them first. - if (_mobState.IsAlive(entity)) - return; - - var user = ev.User; - AlternativeVerb verb = new() - { - Act = () => - { - TryDrink(user, user, entity.Comp, entity); - }, - Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/drink.svg.192dpi.png")), - Text = Loc.GetString("drink-system-verb-drink"), - Priority = 2 - }; - - ev.Verbs.Add(verb); - } - protected FixedPoint2 DrinkVolume(EntityUid uid, DrinkComponent? component = null) { if (!Resolve(uid, ref component)) @@ -98,72 +73,123 @@ public abstract partial class SharedDrinkSystem : EntitySystem } /// - /// Tries to feed the drink item to the target entity + /// Eat or drink an item /// - protected bool TryDrink(EntityUid user, EntityUid target, DrinkComponent drink, EntityUid item) + private void OnUseDrinkInHand(Entity entity, ref UseInHandEvent ev) { - if (!HasComp(target)) - return false; + if (ev.Handled) + return; - if (!_body.TryGetBodyOrganEntityComps(target, out var stomachs)) - return false; + ev.Handled = _ingestion.TryIngest(ev.User, ev.User, entity); + } - if (_openable.IsClosed(item, user, predicted: true)) - return true; + /// + /// Feed someone else + /// + private void OnUseDrink(Entity entity, ref AfterInteractEvent args) + { + if (args.Handled || args.Target == null || !args.CanReach) + return; - if (!_solutionContainer.TryGetSolution(item, drink.Solution, out _, out var drinkSolution) || drinkSolution.Volume <= 0) + args.Handled = _ingestion.TryIngest(args.User, args.Target.Value, entity); + } + + private void AddDrinkVerb(Entity entity, ref GetVerbsEvent args) + { + var user = args.User; + + if (entity.Owner == user || !args.CanInteract || !args.CanAccess) + return; + + if (!_ingestion.TryGetIngestionVerb(user, entity, IngestionSystem.Drink, out var verb)) + return; + + args.Verbs.Add(verb); + } + + private void OnBeforeDrinkEaten(Entity food, ref BeforeIngestedEvent args) + { + if (args.Cancelled) + return; + + // Set it to transfer amount if it exists, otherwise eat the whole volume if possible. + args.Transfer = food.Comp.TransferAmount; + } + + private void OnDrinkEaten(Entity entity, ref IngestedEvent args) + { + if (args.Handled) + return; + + args.Handled = true; + + _audio.PlayPredicted(entity.Comp.UseSound, args.Target, args.User, AudioParams.Default.WithVolume(-2f).WithVariation(0.25f)); + + var flavors = _flavorProfile.GetLocalizedFlavorsMessage(entity.Owner, args.Target, args.Split); + + if (args.ForceFed) { - if (drink.IgnoreEmpty) - return false; + var targetName = Identity.Entity(args.Target, EntityManager); + var userName = Identity.Entity(args.User, EntityManager); - _popup.PopupClient(Loc.GetString("drink-component-try-use-drink-is-empty", ("entity", item)), item, user); - return true; - } + _popup.PopupEntity(Loc.GetString("edible-force-feed-success", ("user", userName), ("verb", _ingestion.GetProtoVerb(IngestionSystem.Drink)), ("flavors", flavors)), entity, entity); - if (_food.IsMouthBlocked(target, user)) - return true; + _popup.PopupClient(Loc.GetString("edible-force-feed-success-user", ("target", targetName), ("verb", _ingestion.GetProtoVerb(IngestionSystem.Drink))), args.User, args.User); - if (!_interaction.InRangeUnobstructed(user, item, popup: true)) - return true; - - var forceDrink = user != target; - - if (forceDrink) - { - var userName = Identity.Entity(user, EntityManager); - - _popup.PopupEntity(Loc.GetString("drink-component-force-feed", ("user", userName)), user, target); - - // logging - _adminLogger.Add(LogType.ForceFeed, LogImpact.High, $"{ToPrettyString(user):user} is forcing {ToPrettyString(target):target} to drink {ToPrettyString(item):drink} {SharedSolutionContainerSystem.ToPrettyString(drinkSolution)}"); + // log successful forced drinking + _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{ToPrettyString(entity.Owner):user} forced {ToPrettyString(args.User):target} to drink {ToPrettyString(entity.Owner):drink}"); } else { - // log voluntary drinking - _adminLogger.Add(LogType.Ingestion, LogImpact.Low, $"{ToPrettyString(target):target} is drinking {ToPrettyString(item):drink} {SharedSolutionContainerSystem.ToPrettyString(drinkSolution)}"); + _popup.PopupClient(Loc.GetString("edible-slurp", ("flavors", flavors)), args.User, args.User); + _popup.PopupEntity(Loc.GetString("edible-slurp"), args.User, Filter.PvsExcept(args.User), true); + + // log successful voluntary drinking + _adminLogger.Add(LogType.Ingestion, LogImpact.Low, $"{ToPrettyString(args.User):target} drank {ToPrettyString(entity.Owner):drink}"); } - var flavors = _flavorProfile.GetLocalizedFlavorsMessage(user, drinkSolution); + if (_ingestion.GetUsesRemaining(entity, entity.Comp.Solution, args.Split.Volume) <= 0) + return; - var doAfterEventArgs = new DoAfterArgs(EntityManager, - user, - forceDrink ? drink.ForceFeedDelay : drink.Delay, - new ConsumeDoAfterEvent(drink.Solution, flavors), - eventTarget: item, - target: target, - used: item) + // Leave some of the consumer's DNA on the consumed item... + var ev = new TransferDnaEvent { - BreakOnHandChange = false, - BreakOnMove = forceDrink, - BreakOnDamage = true, - MovementThreshold = 0.01f, - DistanceThreshold = 1.0f, - // do-after will stop if item is dropped when trying to feed someone else - // or if the item started out in the user's own hands - NeedHand = forceDrink || _hands.IsHolding(user, item), + Donor = args.Target, + Recipient = entity, + CanDnaBeCleaned = false, }; + RaiseLocalEvent(args.Target, ref ev); - _doAfter.TryStartDoAfter(doAfterEventArgs); - return true; + args.Repeat = !args.ForceFed; + } + + private void OnDrink(Entity drink, ref EdibleEvent args) + { + if (args.Cancelled || args.Solution != null) + return; + + if (!_solutionContainer.TryGetSolution(drink.Owner, drink.Comp.Solution, out args.Solution) || IsEmpty(drink)) + { + args.Cancelled = true; + + _popup.PopupClient(Loc.GetString("ingestion-try-use-is-empty", ("entity", drink)), drink, args.User); + return; + } + + args.Time += TimeSpan.FromSeconds(drink.Comp.Delay); + } + + private void OnIsDigestible(Entity ent, ref IsDigestibleEvent args) + { + // Anyone can drink from puddles on the floor! + args.UniversalDigestion(); + } + + private void OnGetEdibleType(Entity ent, ref GetEdibleTypeEvent args) + { + if (args.Type != null) + return; + + args.SetPrototype(IngestionSystem.Drink); } } diff --git a/Content.Shared/Nutrition/EntitySystems/UtensilSystem.cs b/Content.Shared/Nutrition/EntitySystems/UtensilSystem.cs index 63fe822186..e69de29bb2 100644 --- a/Content.Shared/Nutrition/EntitySystems/UtensilSystem.cs +++ b/Content.Shared/Nutrition/EntitySystems/UtensilSystem.cs @@ -1,73 +0,0 @@ -using Content.Shared.Containers.ItemSlots; -using Content.Shared.Interaction; -using Content.Shared.Nutrition.Components; -using Content.Shared.Popups; -using Content.Shared.Tools.EntitySystems; -using Robust.Shared.Audio; -using Robust.Shared.Audio.Systems; -using Robust.Shared.Random; - -namespace Content.Shared.Nutrition.EntitySystems; - -public sealed class UtensilSystem : EntitySystem -{ - [Dependency] private readonly SharedAudioSystem _audio = default!; - [Dependency] private readonly FoodSystem _foodSystem = default!; - [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; - [Dependency] private readonly SharedPopupSystem _popupSystem = default!; - [Dependency] private readonly IRobustRandom _robustRandom = default!; - - public override void Initialize() - { - base.Initialize(); - - SubscribeLocalEvent(OnAfterInteract, after: new[] { typeof(ItemSlotsSystem), typeof(ToolOpenableSystem) }); - } - - /// - /// Clicked with utensil - /// - private void OnAfterInteract(Entity entity, ref AfterInteractEvent ev) - { - if (ev.Handled || ev.Target == null || !ev.CanReach) - return; - - var result = TryUseUtensil(ev.User, ev.Target.Value, entity); - ev.Handled = result.Handled; - } - - public (bool Success, bool Handled) TryUseUtensil(EntityUid user, EntityUid target, Entity utensil) - { - if (!TryComp(target, out FoodComponent? food)) - return (false, false); - - //Prevents food usage with a wrong utensil - if ((food.Utensil & utensil.Comp.Types) == 0) - { - _popupSystem.PopupClient(Loc.GetString("food-system-wrong-utensil", ("food", target), ("utensil", utensil.Owner)), user, user); - return (false, true); - } - - if (!_interactionSystem.InRangeUnobstructed(user, target, popup: true)) - return (false, true); - - return _foodSystem.TryFeed(user, user, target, food); - } - - /// - /// Attempt to break the utensil after interaction. - /// - /// Utensil. - /// User of the utensil. - public void TryBreak(EntityUid uid, EntityUid userUid, UtensilComponent? component = null) - { - if (!Resolve(uid, ref component)) - return; - - if (_robustRandom.Prob(component.BreakChance)) - { - _audio.PlayPredicted(component.BreakSound, userUid, userUid, AudioParams.Default.WithVolume(-2f)); - Del(uid); - } - } -} diff --git a/Content.Shared/Nutrition/IngestionEvents.cs b/Content.Shared/Nutrition/IngestionEvents.cs index 685b08b1bd..27988c898d 100644 --- a/Content.Shared/Nutrition/IngestionEvents.cs +++ b/Content.Shared/Nutrition/IngestionEvents.cs @@ -1,9 +1,52 @@ +using Content.Shared.Chemistry.Components; +using Content.Shared.DoAfter; +using Content.Shared.FixedPoint; +using Content.Shared.Inventory; +using Content.Shared.Nutrition.Components; +using Content.Shared.Nutrition.Prototypes; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; + namespace Content.Shared.Nutrition; /// -/// Raised directed at the consumer when attempting to ingest something. +/// Raised on an entity that is trying to be ingested to see if it has universal blockers preventing it from being +/// ingested. /// -public sealed class IngestionAttemptEvent : CancellableEntityEventArgs +[ByRefEvent] +public record struct IngestibleEvent(bool Cancelled = false); + +/// +/// Raised on an entity with the to check if anything is stopping +/// another entity from consuming the delicious reagents stored inside. +/// +/// The entity trying to feed us to an entity. +[ByRefEvent] +public record struct EdibleEvent(EntityUid User) +{ + public Entity? Solution = null; + + public TimeSpan Time = TimeSpan.Zero; + + public bool Cancelled; +} + +/// +/// Raised when an entity is trying to ingest an entity to see if it has any component that can ingest it. +/// +/// Did a system successfully ingest this item? +/// The entity that is trying to feed and therefore raising the event +/// What are we trying to ingest? +/// Should we actually try and ingest? Or are we just testing if it's even possible +[ByRefEvent] +public record struct AttemptIngestEvent(EntityUid User, EntityUid Ingested, bool Ingest, bool Handled = false); + +/// +/// Raised on an entity that is consuming another entity to see if there is anything attached to the entity +/// that is preventing it from doing the consumption. +/// +[ByRefEvent] +public record struct IngestionAttemptEvent(SlotFlags TargetSlots, bool Cancelled = false) : IInventoryRelayEvent { /// /// The equipment that is blocking consumption. Should only be non-null if the event was canceled. @@ -12,22 +55,113 @@ public sealed class IngestionAttemptEvent : CancellableEntityEventArgs } /// -/// Raised directed at the food after finishing eating a food before it's deleted. -/// Cancel this if you want to do something special before a food is deleted. +/// Raised on an entity that is trying to be digested, aka turned from an entity into reagents. +/// Returns its digestive properties or how difficult it is to convert to reagents. /// -public sealed class BeforeFullyEatenEvent : CancellableEntityEventArgs +/// This method is currently needed for backwards compatibility with food and drink component. +/// It also might be needed in the event items like trash and plushies have their edible component removed. +/// There's no way to know whether this event will be made obsolete or not after Food and Drink Components +/// are removed until after a proper body and digestion rework. Oh well! +/// +[ByRefEvent] +public record struct IsDigestibleEvent() { - /// - /// The person that ate the food. - /// - public EntityUid User; + public bool Digestible = false; + + public bool SpecialDigestion = false; + + // If this is true, SpecialDigestion will be ignored + public bool Universal = false; + + // If it requires special digestion then it has to be digestible... + public void AddDigestible(bool special) + { + SpecialDigestion = special; + Digestible = true; + } + + // This should only be used for if you're trying to drink pure reagents from a puddle or cup or something... + public void UniversalDigestion() + { + Universal = true; + Digestible = true; + } +} + +/// +/// Do After Event for trying to put food solution into stomach entity. +/// +[Serializable, NetSerializable] +public sealed partial class EatingDoAfterEvent : SimpleDoAfterEvent; + +/// +/// We use this to determine if an entity should abort giving up its reagents at the last minute, +/// as well as specifying how much of its reagents it should give up including minimums and maximums. +/// If minimum exceeds the maximum, the event will abort. +/// +/// The minimum amount we can transfer. +/// The maximum amount we can transfer. +/// The solution we are transferring. +[ByRefEvent] +public record struct BeforeIngestedEvent(FixedPoint2 Min, FixedPoint2 Max, Solution? Solution) +{ + // How much we would like to transfer, gets clamped by Min and Max. + public FixedPoint2 Transfer; + + // Whether this event, and therefore eat attempt, should be cancelled. + public bool Cancelled; + + public bool TryNewMinimum(FixedPoint2 newMin) + { + if (newMin > Max) + return false; + + Min = newMin; + return true; + } + + public bool TryNewMaximum(FixedPoint2 newMax) + { + if (newMax < Min) + return false; + + Min = newMax; + return true; + } +} + +[ByRefEvent] +public record struct IngestingEvent(EntityUid Food, Solution Split, bool ForceFed); + +/// +/// Raised on an entity when it is being made to be eaten. +/// +/// Who is doing the action? +/// Who is doing the eating? +/// The solution we're currently eating. +/// Whether we're being fed by someone else, checkec enough I might as well pass it. +[ByRefEvent] +public record struct IngestedEvent(EntityUid User, EntityUid Target, Solution Split, bool ForceFed) +{ + // Should we refill the solution now that we've eaten it? + // This bool basically only exists because of stackable system. + public bool Refresh; + + // Should we destroy the ingested entity? + public bool Destroy; + + // Has this eaten event been handled? Used to prevent duplicate flavor popups and sound effects. + public bool Handled; + + // Should we try eating again? + public bool Repeat; } /// /// Raised directed at the food after finishing eating it and before it's deleted. /// [ByRefEvent] -public readonly record struct AfterFullyEatenEvent(EntityUid User) +public readonly record struct FullyEatenEvent(EntityUid User) { /// /// The entity that ate the food. @@ -35,6 +169,38 @@ public readonly record struct AfterFullyEatenEvent(EntityUid User) public readonly EntityUid User = User; } +/// +/// Returns a list of Utensils that can be used to consume the entity, as well as a list of required types. +/// +[ByRefEvent] +public record struct GetUtensilsEvent() +{ + public UtensilType Types = UtensilType.None; + + public UtensilType RequiredTypes = UtensilType.None; + + // Forces you to add to both lists if a utensil is required. + public void AddRequiredTypes(UtensilType type) + { + RequiredTypes |= type; + Types |= type; + } +} + +/// +/// Tries to get the best fitting edible type for an entity. +/// +[ByRefEvent] +public record struct GetEdibleTypeEvent +{ + public ProtoId? Type { get; private set; } + + public void SetPrototype([ForbidLiteral] ProtoId proto) + { + Type = proto; + } +} + /// /// Raised directed at the food being sliced before it's deleted. /// Cancel this if you want to do something special before a food is deleted. diff --git a/Content.Shared/Nutrition/Prototypes/EdiblePrototype.cs b/Content.Shared/Nutrition/Prototypes/EdiblePrototype.cs new file mode 100644 index 0000000000..0f4c23846a --- /dev/null +++ b/Content.Shared/Nutrition/Prototypes/EdiblePrototype.cs @@ -0,0 +1,54 @@ +using Robust.Shared.Audio; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Shared.Nutrition.Prototypes; + +/// +/// This stores unique data for an item that is edible, such as verbs, verb icons, verb names, sounds, ect. +/// +[Prototype] +public sealed partial class EdiblePrototype : IPrototype +{ + /// + [IdDataField] + public string ID { get; } = default!; + + /// + /// The sound we make when eaten. + /// + [DataField] + public SoundSpecifier UseSound = new SoundCollectionSpecifier("eating"); + + /// + /// The localization identifier for the ingestion message. + /// + [DataField] + public LocId Message; + + /// + /// Localization verb used when consuming this item. + /// + [DataField] + public LocId Verb; + + /// + /// Localization noun used when consuming this item. + /// + [DataField] + public LocId Noun; + + /// + /// What type of food are we, currently used for determining verbs and some checks. + /// + [DataField] + public LocId VerbName; + + /// + /// What type of food are we, currently used for determining verbs and some checks. + /// + [DataField] + public SpriteSpecifier? VerbIcon; + + +} diff --git a/Content.Shared/Stacks/SharedStackSystem.cs b/Content.Shared/Stacks/SharedStackSystem.cs index cd2f38d47f..912089379a 100644 --- a/Content.Shared/Stacks/SharedStackSystem.cs +++ b/Content.Shared/Stacks/SharedStackSystem.cs @@ -3,6 +3,7 @@ using Content.Shared.Examine; using Content.Shared.Hands.Components; using Content.Shared.Hands.EntitySystems; using Content.Shared.Interaction; +using Content.Shared.Nutrition; using Content.Shared.Popups; using Content.Shared.Storage.EntitySystems; using JetBrains.Annotations; @@ -37,6 +38,8 @@ namespace Content.Shared.Stacks SubscribeLocalEvent(OnStackStarted); SubscribeLocalEvent(OnStackExamined); SubscribeLocalEvent(OnStackInteractUsing); + SubscribeLocalEvent(OnBeforeEaten); + SubscribeLocalEvent(OnEaten); _vvm.GetTypeHandler() .AddPath(nameof(StackComponent.Count), (_, comp) => comp.Count, SetCount); @@ -389,6 +392,51 @@ namespace Content.Shared.Stacks ) ); } + + private void OnBeforeEaten(Entity eaten, ref BeforeIngestedEvent args) + { + if (args.Cancelled) + return; + + if (args.Solution is not { } sol) + return; + + // If the entity is empty and is a lingering entity we can't eat from it. + if (eaten.Comp.Count <= 0) + { + args.Cancelled = true; + return; + } + + /* + Edible stacked items is near completely evil so we must choose one of the following: + - Option 1: Eat the entire solution each bite and reduce the stack by 1. + - Option 2: Multiply the solution eaten by the stack size. + - Option 3: Divide the solution consumed by stack size. + The easiest and safest option is and always will be Option 1 otherwise we risk reagent deletion or duplication. + That is why we cancel if we cannot set the minimum to the entire volume of the solution. + */ + if(args.TryNewMinimum(sol.Volume)) + return; + + args.Cancelled = true; + } + + private void OnEaten(Entity eaten, ref IngestedEvent args) + { + if (!Use(eaten, 1)) + return; + + // We haven't eaten the whole stack yet or are unable to eat it completely. + if (eaten.Comp.Count > 0 || eaten.Comp.Lingering) + { + args.Refresh = true; + return; + } + + // Here to tell the food system to do destroy stuff. + args.Destroy = true; + } } /// diff --git a/Content.Shared/Storage/EntitySystems/SecretStashSystem.cs b/Content.Shared/Storage/EntitySystems/SecretStashSystem.cs index 51615c5afa..3691f6e2b6 100644 --- a/Content.Shared/Storage/EntitySystems/SecretStashSystem.cs +++ b/Content.Shared/Storage/EntitySystems/SecretStashSystem.cs @@ -8,6 +8,7 @@ using Content.Shared.Interaction; using Content.Shared.Item; using Content.Shared.Materials; using Content.Shared.Nutrition; +using Content.Shared.Nutrition.EntitySystems; using Content.Shared.Popups; using Content.Shared.Storage.Components; using Content.Shared.Tools.EntitySystems; @@ -25,6 +26,7 @@ namespace Content.Shared.Storage.EntitySystems; /// public sealed class SecretStashSystem : EntitySystem { + [Dependency] private readonly IngestionSystem _ingestion = default!; [Dependency] private readonly SharedPopupSystem _popupSystem = default!; [Dependency] private readonly SharedHandsSystem _handsSystem = default!; [Dependency] private readonly SharedContainerSystem _containerSystem = default!; @@ -41,7 +43,7 @@ public sealed class SecretStashSystem : EntitySystem SubscribeLocalEvent(OnDestroyed); SubscribeLocalEvent(OnReclaimed); SubscribeLocalEvent(OnInteractUsing, after: new[] { typeof(ToolOpenableSystem), typeof(AnchorableSystem) }); - SubscribeLocalEvent(OnEaten); + SubscribeLocalEvent(OnFullyEaten); SubscribeLocalEvent(OnInteractHand); SubscribeLocalEvent>(OnGetVerb); } @@ -61,7 +63,7 @@ public sealed class SecretStashSystem : EntitySystem DropContentsAndAlert(entity, args.ReclaimerCoordinates); } - private void OnEaten(Entity entity, ref AfterFullyEatenEvent args) + private void OnFullyEaten(Entity entity, ref FullyEatenEvent args) { // TODO: When newmed is finished should do damage to teeth (Or something like that!) var damage = entity.Comp.DamageEatenItemInside; diff --git a/Resources/Locale/en-US/nutrition/components/drink-component.ftl b/Resources/Locale/en-US/nutrition/components/drink-component.ftl index ab458746dd..e69de29bb2 100644 --- a/Resources/Locale/en-US/nutrition/components/drink-component.ftl +++ b/Resources/Locale/en-US/nutrition/components/drink-component.ftl @@ -1,18 +0,0 @@ -drink-component-on-use-is-empty = {CAPITALIZE(THE($owner))} is empty! -drink-component-on-examine-is-opened = [color=yellow]Opened[/color] -drink-component-on-examine-is-sealed = The seal is intact. -drink-component-on-examine-is-unsealed = The seal is broken. -drink-component-try-use-drink-not-open = Open {$owner} first! -drink-component-try-use-drink-is-empty = {CAPITALIZE(THE($entity))} is empty! -drink-component-try-use-drink-cannot-drink = You can't drink anything! -drink-component-try-use-drink-had-enough = You can't drink more! -drink-component-try-use-drink-cannot-drink-other = They can't drink anything! -drink-component-try-use-drink-had-enough-other = They can't drink more! -drink-component-try-use-drink-success-slurp = Slurp -drink-component-try-use-drink-success-slurp-taste = Slurp. {$flavors} -drink-component-force-feed = {CAPITALIZE(THE($user))} is trying to make you drink something! -drink-component-force-feed-success = {CAPITALIZE(THE($user))} forced you to drink something! {$flavors} -drink-component-force-feed-success-user = You successfully feed {THE($target)} - - -drink-system-verb-drink = Drink diff --git a/Resources/Locale/en-US/nutrition/components/food-component.ftl b/Resources/Locale/en-US/nutrition/components/food-component.ftl deleted file mode 100644 index 2247ef6fd4..0000000000 --- a/Resources/Locale/en-US/nutrition/components/food-component.ftl +++ /dev/null @@ -1,29 +0,0 @@ - -### Interaction Messages - -# When trying to eat food without the required utensil... but you gotta hold it -food-you-need-to-hold-utensil = You need to be holding {INDEFINITE($utensil)} {$utensil} to eat that! - -food-nom = Nom. {$flavors} -food-swallow = You swallow { THE($food) }. {$flavors} - -food-has-used-storage = You cannot eat { THE($food) } with an item stored inside. - -food-system-remove-mask = You need to take off the {$entity} first. - -## System - -food-system-you-cannot-eat-any-more = You can't eat any more! -food-system-you-cannot-eat-any-more-other = {CAPITALIZE(SUBJECT($target))} can't eat any more! -food-system-try-use-food-is-empty = {CAPITALIZE(THE($entity))} is empty! -food-system-wrong-utensil = You can't eat {THE($food)} with {INDEFINITE($utensil)} {$utensil}. -food-system-cant-digest = You can't digest {THE($entity)}! -food-system-cant-digest-other = {CAPITALIZE(SUBJECT($target))} can't digest {THE($entity)}! - -food-system-verb-eat = Eat - -## Force feeding - -food-system-force-feed = {CAPITALIZE(THE($user))} is trying to feed you something! -food-system-force-feed-success = {CAPITALIZE(THE($user))} forced you to eat something! {$flavors} -food-system-force-feed-success-user = You successfully feed {THE($target)} diff --git a/Resources/Locale/en-US/nutrition/components/ingestion-system.ftl b/Resources/Locale/en-US/nutrition/components/ingestion-system.ftl new file mode 100644 index 0000000000..692100e61a --- /dev/null +++ b/Resources/Locale/en-US/nutrition/components/ingestion-system.ftl @@ -0,0 +1,53 @@ +### Interaction Messages + +# System + +## When trying to ingest without the required utensil... but you gotta hold it +ingestion-you-need-to-hold-utensil = You need to be holding {INDEFINITE($utensil)} {$utensil} to eat that! + +ingestion-try-use-is-empty = {CAPITALIZE(THE($entity))} is empty! +ingestion-try-use-wrong-utensil = You can't {$verb} {THE($food)} with {INDEFINITE($utensil)} {$utensil}. + +ingestion-remove-mask = You need to take off the {$entity} first. + +## Failed Ingestion + +ingestion-you-cannot-ingest-any-more = You can't {$verb} any more! +ingestion-other-cannot-ingest-any-more = {CAPITALIZE(SUBJECT($target))} can't {$verb} any more! + +ingestion-cant-digest = You can't digest {THE($entity)}! +ingestion-cant-digest-other = {CAPITALIZE(SUBJECT($target))} can't digest {THE($entity)}! + +## Action Verbs, not to be confused with Verbs + +ingestion-verb-food = Eat +ingestion-verb-drink = Drink + +# Edible Component + +edible-nom = Nom. {$flavors} +edible-slurp = Slurp. {$flavors} +edible-swallow = You swallow { THE($food) } +edible-gulp = Gulp. {$flavors} + +edible-has-used-storage = You cannot {$verb} { THE($food) } with an item stored inside. + +## Nouns + +edible-noun-edible = edible +edible-noun-food = food +edible-noun-drink = drink +edible-noun-pill = pill + +## Verbs + +edible-verb-edible = ingest +edible-verb-food = eat +edible-verb-drink = drink +edible-verb-pill = swallow + +## Force feeding + +edible-force-feed = {CAPITALIZE(THE($user))} is trying to make you {$verb} something! +edible-force-feed-success = {CAPITALIZE(THE($user))} forced you to {$verb} something! {$flavors} +edible-force-feed-success-user = You successfully feed {THE($target)} diff --git a/Resources/Locale/en-US/nutrition/components/openable-component.ftl b/Resources/Locale/en-US/nutrition/components/openable-component.ftl index 3acc24cf53..786885e658 100644 --- a/Resources/Locale/en-US/nutrition/components/openable-component.ftl +++ b/Resources/Locale/en-US/nutrition/components/openable-component.ftl @@ -1,2 +1,5 @@ openable-component-verb-open = Open openable-component-verb-close = Close + +openable-component-on-examine-is-opened = [color=yellow]Opened[/color] +openable-component-try-use-closed = Open {$owner} first! diff --git a/Resources/Locale/en-US/nutrition/components/sealable-component.ftl b/Resources/Locale/en-US/nutrition/components/sealable-component.ftl new file mode 100644 index 0000000000..e826e174ef --- /dev/null +++ b/Resources/Locale/en-US/nutrition/components/sealable-component.ftl @@ -0,0 +1,2 @@ +sealable-component-on-examine-is-sealed = The seal is intact. +sealable-component-on-examine-is-unsealed = The seal is broken. diff --git a/Resources/Prototypes/Body/Organs/Animal/ruminant.yml b/Resources/Prototypes/Body/Organs/Animal/ruminant.yml index 3b00e1a223..6b64aa4c1d 100644 --- a/Resources/Prototypes/Body/Organs/Animal/ruminant.yml +++ b/Resources/Prototypes/Body/Organs/Animal/ruminant.yml @@ -4,6 +4,12 @@ name: ruminant stomach categories: [ HideSpawnMenu ] components: + - type: Stomach + specialDigestible: + tags: + - Ruminant + - Wheat + - BananaPeel - type: SolutionContainerManager solutions: stomach: diff --git a/Resources/Prototypes/Entities/Clothing/Head/misc.yml b/Resources/Prototypes/Entities/Clothing/Head/misc.yml index be8727008a..96844d4017 100644 --- a/Resources/Prototypes/Entities/Clothing/Head/misc.yml +++ b/Resources/Prototypes/Entities/Clothing/Head/misc.yml @@ -315,15 +315,16 @@ - type: Clothing slots: - HEAD - - type: Food + - type: Edible + edible: Drink solution: drink - useSound: /Audio/Items/drink.ogg - eatMessage: drink-component-try-use-drink-success-slurp delay: 0.5 forceFeedDelay: 1.5 - type: FlavorProfile flavors: - water + - type: DrainableSolution + solution: drink - type: SolutionContainerManager solutions: drink: diff --git a/Resources/Prototypes/Entities/Effects/puddle.yml b/Resources/Prototypes/Entities/Effects/puddle.yml index 1b84dcd7d0..cc3df59c55 100644 --- a/Resources/Prototypes/Entities/Effects/puddle.yml +++ b/Resources/Prototypes/Entities/Effects/puddle.yml @@ -201,7 +201,8 @@ - type: EdgeSpreader id: Puddle - type: StepTrigger - - type: Drink + - type: Edible + edible: Drink delay: 3 transferAmount: 1 solution: puddle diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml index 920468605f..da68ee109b 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml @@ -1054,10 +1054,10 @@ growthDelay: 20 - type: ExaminableHunger - type: Wooly - - type: Food + - type: Edible + destroyOnEmpty: false solution: wool requiresSpecialDigestion: true - # Wooly prevents eating wool deleting the goat so its fine requireDead: false - type: FlavorProfile flavors: diff --git a/Resources/Prototypes/Entities/Objects/Consumable/Drinks/drinks.yml b/Resources/Prototypes/Entities/Objects/Consumable/Drinks/drinks.yml index 2d8fadfa43..4df720d541 100644 --- a/Resources/Prototypes/Entities/Objects/Consumable/Drinks/drinks.yml +++ b/Resources/Prototypes/Entities/Objects/Consumable/Drinks/drinks.yml @@ -14,7 +14,10 @@ solution: drink - type: SolutionTransfer canChangeTransferAmount: true - - type: Drink + - type: Edible + edible: Drink + solution: drink + destroyOnEmpty: false - type: Sprite state: icon - type: MeleeWeapon diff --git a/Resources/Prototypes/Entities/Objects/Consumable/Food/burger.yml b/Resources/Prototypes/Entities/Objects/Consumable/Food/burger.yml index b226620953..6d17f4fbbb 100644 --- a/Resources/Prototypes/Entities/Objects/Consumable/Food/burger.yml +++ b/Resources/Prototypes/Entities/Objects/Consumable/Food/burger.yml @@ -31,10 +31,10 @@ id: FoodBreadBunBottom parent: FoodBreadSliceBase name: bottom bun - description: It's time to start building the burger tower. + description: It's time to start building the burger tower. components: - type: Item - size: Normal #patch until there is an adequate resizing system in place + size: Normal #patch until there is an adequate resizing system in place - type: Food - type: Sprite drawdepth: Mobs @@ -83,7 +83,7 @@ - type: FoodSequenceElement entries: Burger: BunTopBurger - + # Base - type: entity @@ -95,8 +95,6 @@ flavors: - bun - meaty - - type: Food - transferAmount: 5 - type: Sprite sprite: Objects/Consumable/Food/burger.rsi - type: SolutionContainerManager @@ -499,7 +497,7 @@ - ReagentId: Vitamin Quantity: 8 - ReagentId: Sulfur # What you get for eating something with a flare in it - Quantity: 5 + Quantity: 5 - type: Tag tags: - Meat @@ -691,7 +689,7 @@ description: An elusive rib shaped burger with limited availability across the galaxy. Not as good as you remember it. components: - type: Food - trash: + trash: - FoodKebabSkewer - type: FlavorProfile flavors: @@ -702,7 +700,7 @@ - type: SolutionContainerManager solutions: food: - maxVol: 30 + maxVol: 30 reagents: - ReagentId: Nutriment Quantity: 11 @@ -987,4 +985,4 @@ - type: Tag tags: - Meat - + diff --git a/Resources/Prototypes/Entities/Objects/Consumable/Food/food_base.yml b/Resources/Prototypes/Entities/Objects/Consumable/Food/food_base.yml index ddacf71a03..fedce70e79 100644 --- a/Resources/Prototypes/Entities/Objects/Consumable/Food/food_base.yml +++ b/Resources/Prototypes/Entities/Objects/Consumable/Food/food_base.yml @@ -9,7 +9,7 @@ - type: FlavorProfile flavors: - food - - type: Food + - type: Edible - type: Sprite - type: StaticPrice price: 0 diff --git a/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml b/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml index 93c48b69c2..721c2e3e38 100644 --- a/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml +++ b/Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml @@ -10,13 +10,16 @@ - type: Sprite state: produce # let cows eat raw produce like wheat and oats - - type: Food - requiredStomachs: 2 + - type: Edible + requiresSpecialDigestion: true - type: Produce - type: PotencyVisuals - type: Appearance - type: Extractable grindableSolutionName: food + - type: Tag + tags: + - Ruminant # For produce that can be immediately eaten @@ -57,6 +60,7 @@ - type: Tag tags: - Wheat + - Ruminant - type: entity name: meatwheat bushel @@ -176,6 +180,7 @@ - type: Tag tags: - Vegetable + - Ruminant - type: entity name: tower-cap log @@ -323,7 +328,7 @@ - type: FlavorProfile flavors: - banana - - type: Food + - type: Edible trash: - TrashBananaPeel - type: SolutionContainerManager @@ -365,7 +370,7 @@ flavors: - banana - nothing - - type: Food + - type: Edible trash: - TrashMimanaPeel - type: SolutionContainerManager @@ -437,6 +442,7 @@ - Recyclable - Trash - BananaPeel + - Ruminant - WhitelistChameleon - HamsterWearable - type: SolutionContainerManager @@ -449,7 +455,7 @@ - type: Extractable grindableSolutionName: food - type: SpaceGarbage - - type: Food + - type: Edible requiresSpecialDigestion: true - type: Clothing sprite: Objects/Specific/Hydroponics/banana.rsi @@ -1190,7 +1196,7 @@ - type: FlavorProfile flavors: - corn - - type: Food + - type: Edible trash: - FoodCornTrash - type: SolutionContainerManager @@ -1895,7 +1901,7 @@ sprite: Objects/Specific/Hydroponics/gatfruit.rsi - type: Produce seedId: gatfruit - - type: Food + - type: Edible trash: - WeaponRevolverPython - type: Tag @@ -1930,7 +1936,7 @@ heldPrefix: produce - type: Produce seedId: realCapfruit - - type: Food + - type: Edible trash: - RevolverCapGun - type: Tag @@ -1949,7 +1955,7 @@ components: - type: Produce seedId: fakeCapfruit - - type: Food + - type: Edible trash: - RevolverCapGunFake @@ -2353,7 +2359,7 @@ - type: FlavorProfile flavors: - bungo - - type: Food + - type: Edible trash: - FoodBungoPit - type: SolutionContainerManager @@ -2588,7 +2594,7 @@ - type: FlavorProfile flavors: - cotton - - type: Food + - type: Edible requiresSpecialDigestion: true - type: SolutionContainerManager solutions: @@ -2620,7 +2626,7 @@ - type: FlavorProfile flavors: - pyrotton - - type: Food + - type: Edible requiresSpecialDigestion: true - type: SolutionContainerManager solutions: @@ -2654,7 +2660,7 @@ - type: FlavorProfile flavors: - cherry - - type: Food + - type: Edible trash: - TrashCherryPit - type: SolutionContainerManager @@ -2729,7 +2735,7 @@ heldPrefix: produce - type: Produce seedId: anomalyBerry - - type: Food + - type: Edible trash: - EffectAnomalyFloraBulb # Random loot - type: SolutionContainerManager diff --git a/Resources/Prototypes/Entities/Objects/Materials/materials.yml b/Resources/Prototypes/Entities/Objects/Materials/materials.yml index 5e04ac55dc..a64566f258 100644 --- a/Resources/Prototypes/Entities/Objects/Materials/materials.yml +++ b/Resources/Prototypes/Entities/Objects/Materials/materials.yml @@ -123,7 +123,7 @@ - state: cloth_3 map: ["base"] - type: Appearance - - type: Food + - type: Edible requiresSpecialDigestion: true - type: FlavorProfile flavors: @@ -192,7 +192,7 @@ - type: Construction graph: Durathread node: MaterialDurathread - - type: Food + - type: Edible requiresSpecialDigestion: true - type: SolutionContainerManager solutions: @@ -421,7 +421,7 @@ - state: cotton_3 map: ["base"] - type: Appearance - - type: Food + - type: Edible requiresSpecialDigestion: true - type: FlavorProfile flavors: @@ -480,7 +480,7 @@ - state: pyrotton_3 map: ["base"] - type: Appearance - - type: Food + - type: Edible requiresSpecialDigestion: true - type: SolutionContainerManager solutions: @@ -540,7 +540,7 @@ - type: FlavorProfile flavors: - banana - - type: Food + - type: Edible trash: - TrashBananiumPeel - type: BadFood @@ -592,7 +592,7 @@ - type: Stack count: 50 stackType: WebSilk - - type: Food + - type: Edible requiresSpecialDigestion: true - type: FlavorProfile flavors: @@ -735,7 +735,7 @@ - state: cotton_3 map: ["base"] - type: Appearance - - type: Food + - type: Edible - type: BadFood - type: SolutionContainerManager solutions: diff --git a/Resources/Prototypes/Entities/Objects/Misc/kudzu.yml b/Resources/Prototypes/Entities/Objects/Misc/kudzu.yml index e35e915b09..6c01a2a8dd 100644 --- a/Resources/Prototypes/Entities/Objects/Misc/kudzu.yml +++ b/Resources/Prototypes/Entities/Objects/Misc/kudzu.yml @@ -90,7 +90,7 @@ components: - IgnoreKudzu - type: Food - requiredStomachs: 2 # ruminants have 4 stomachs but i dont care to give them literally 4 stomachs. 2 is good + requiresSpecialDigestion: true delay: 0.5 - type: FlavorProfile flavors: @@ -101,6 +101,9 @@ reagents: - ReagentId: Nutriment Quantity: 2 + - type: Tag + tags: + - Ruminant - type: entity id: WeakKudzu diff --git a/Resources/Prototypes/Entities/Objects/Specific/chemistry.yml b/Resources/Prototypes/Entities/Objects/Specific/chemistry.yml index 3a7e44775f..70ea8080ae 100644 --- a/Resources/Prototypes/Entities/Objects/Specific/chemistry.yml +++ b/Resources/Prototypes/Entities/Objects/Specific/chemistry.yml @@ -602,12 +602,11 @@ size: Tiny sprite: Objects/Specific/Chemistry/pills.rsi - type: Pill - - type: Food + - type: Edible delay: 0.6 forceFeedDelay: 2 transferAmount: null - eatMessage: food-swallow - useSound: /Audio/Items/pill.ogg + edible: Pill - type: BadFood - type: FlavorProfile ignoreReagents: [] diff --git a/Resources/Prototypes/Entities/Objects/Tools/bucket.yml b/Resources/Prototypes/Entities/Objects/Tools/bucket.yml index 992c3313e2..d77e6cd2b8 100644 --- a/Resources/Prototypes/Entities/Objects/Tools/bucket.yml +++ b/Resources/Prototypes/Entities/Objects/Tools/bucket.yml @@ -1,13 +1,14 @@ - type: entity - parent: BaseItem + parent: DrinkBase id: Bucket name: bucket description: It's a boring old bucket. components: - - type: Drink - solution: bucket - ignoreEmpty: true - type: Clickable + - type: Edible + edible: Drink + solution: bucket + destroyOnEmpty: false - type: Sprite sprite: Objects/Tools/bucket.rsi layers: diff --git a/Resources/Prototypes/NPCs/utility_queries.yml b/Resources/Prototypes/NPCs/utility_queries.yml index 23ad7a59a1..3274bdf977 100644 --- a/Resources/Prototypes/NPCs/utility_queries.yml +++ b/Resources/Prototypes/NPCs/utility_queries.yml @@ -31,7 +31,7 @@ query: - !type:ComponentQuery components: - - type: Food + - type: Edible considerations: - !type:TargetIsAliveCon curve: !type:InverseBoolCurve @@ -50,7 +50,7 @@ query: - !type:ComponentQuery components: - - type: Drink + - type: Edible considerations: - !type:TargetIsAliveCon curve: !type:InverseBoolCurve diff --git a/Resources/Prototypes/Nutrition/edible.yml b/Resources/Prototypes/Nutrition/edible.yml new file mode 100644 index 0000000000..1b29bbed69 --- /dev/null +++ b/Resources/Prototypes/Nutrition/edible.yml @@ -0,0 +1,47 @@ +# If you add a new prototype, you may want to consider adding it to IngestionSystem.API for other systems to use. +# But only if other systems/components might want it. + +# Food + +- type: edible + id: Food + useSound: !type:SoundCollectionSpecifier + params: + variation: 0.2 + volume: -1 + collection: eating # I think this *should* grab the sound specifier... + message: edible-nom + verb: edible-verb-food + noun: edible-noun-food + verbName: ingestion-verb-food + verbIcon: /Textures/Interface/VerbIcons/cutlery.svg.192dpi.png + +# Drink + +- type: edible + id: Drink + useSound: !type:SoundPathSpecifier + params: + variation: 0.25 + volume: -2 + path: /Audio/Items/drink.ogg + message: edible-slurp + verb: edible-verb-drink + noun: edible-noun-drink + verbName: ingestion-verb-drink + verbIcon: /Textures/Interface/VerbIcons/drink.svg.192dpi.png + +# Pills! + +- type: edible + id: Pill + useSound: !type:SoundPathSpecifier + params: + variation: 0.2 + volume: -1 + path: /Audio/Items/pill.ogg + message: edible-swallow + verb: edible-verb-pill + noun: edible-noun-pill + verbName: ingestion-verb-food + verbIcon: /Textures/Interface/VerbIcons/cutlery.svg.192dpi.png diff --git a/Resources/Prototypes/tags.yml b/Resources/Prototypes/tags.yml index 24e21742c3..5d34f0f30c 100644 --- a/Resources/Prototypes/tags.yml +++ b/Resources/Prototypes/tags.yml @@ -1199,6 +1199,9 @@ - type: Tag id: RollingPin +- type: Tag + id: Ruminant + - type: Tag id: SaltShaker