diff --git a/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml b/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml
new file mode 100644
index 00000000000..6a3072ce08b
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml.cs b/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml.cs
new file mode 100644
index 00000000000..b4f25a02e4c
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodComposition.xaml.cs
@@ -0,0 +1,31 @@
+using Content.Client.UserInterface.ControlExtensions;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.FixedPoint;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Guidebook.Controls;
+
+[UsedImplicitly, GenerateTypedNameReferences]
+public sealed partial class GuideFoodComposition : BoxContainer, ISearchableControl
+{
+ public GuideFoodComposition(ReagentPrototype proto, FixedPoint2 quantity)
+ {
+ RobustXamlLoader.Load(this);
+
+ ReagentLabel.Text = proto.LocalizedName;
+ AmountLabel.Text = quantity.ToString();
+ }
+
+ public bool CheckMatchesSearch(string query)
+ {
+ return this.ChildrenContainText(query);
+ }
+
+ public void SetHiddenState(bool state, string query)
+ {
+ Visible = CheckMatchesSearch(query) ? state : !state;
+ }
+}
diff --git a/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml b/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml
new file mode 100644
index 00000000000..8aa2b10356e
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml.cs
new file mode 100644
index 00000000000..355d3a020f8
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodEmbed.xaml.cs
@@ -0,0 +1,161 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Client.Chemistry.EntitySystems;
+using Content.Client.Guidebook.Richtext;
+using Content.Client.Message;
+using Content.Client.Nutrition.EntitySystems;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.FixedPoint;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Guidebook.Controls;
+
+///
+/// Control for embedding a food recipe into a guidebook.
+///
+[UsedImplicitly, GenerateTypedNameReferences]
+public sealed partial class GuideFoodEmbed : BoxContainer, IDocumentTag, ISearchableControl
+{
+ [Dependency] private readonly IEntitySystemManager _systemManager = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+
+ private readonly FoodGuideDataSystem _foodGuideData;
+ private readonly ISawmill _logger = default!;
+
+ public GuideFoodEmbed()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ _foodGuideData = _systemManager.GetEntitySystem();
+ _logger = Logger.GetSawmill("food guide");
+ MouseFilter = MouseFilterMode.Stop;
+ }
+
+ public GuideFoodEmbed(FoodGuideEntry entry) : this()
+ {
+ GenerateControl(entry);
+ }
+
+ public bool CheckMatchesSearch(string query)
+ {
+ return FoodName.GetMessage()?.Contains(query, StringComparison.InvariantCultureIgnoreCase) == true
+ || FoodDescription.GetMessage()?.Contains(query, StringComparison.InvariantCultureIgnoreCase) == true;
+ }
+
+ public void SetHiddenState(bool state, string query)
+ {
+ Visible = CheckMatchesSearch(query) ? state : !state;
+ }
+
+ public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control)
+ {
+ control = null;
+ if (!args.TryGetValue("Food", out var id))
+ {
+ _logger.Error("Food embed tag is missing food prototype argument.");
+ return false;
+ }
+
+ if (!_foodGuideData.TryGetData(id, out var data))
+ {
+ _logger.Warning($"Specified food prototype \"{id}\" does not have any known sources.");
+ return false;
+ }
+
+ GenerateControl(data);
+
+ control = this;
+ return true;
+ }
+
+ private void GenerateControl(FoodGuideEntry data)
+ {
+ _prototype.TryIndex(data.Result, out var proto);
+ if (proto == null)
+ {
+ FoodName.SetMarkup(Loc.GetString("guidebook-food-unknown-proto", ("id", data.Result)));
+ return;
+ }
+
+ var composition = data.Composition
+ .Select(it => _prototype.TryIndex(it.Reagent.Prototype, out var reagent) ? (reagent, it.Quantity) : (null, 0))
+ .Where(it => it.reagent is not null)
+ .Cast<(ReagentPrototype, FixedPoint2)>()
+ .ToList();
+
+ #region Colors
+
+ CalculateColors(composition, out var textColor, out var backgroundColor);
+
+ NameBackground.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = backgroundColor
+ };
+ FoodName.SetMarkup(Loc.GetString("guidebook-food-name", ("color", textColor), ("name", proto.Name)));
+
+ #endregion
+
+ #region Sources
+
+ foreach (var source in data.Sources.OrderBy(it => it.OutputCount))
+ {
+ var control = new GuideFoodSource(proto, source, _prototype);
+ SourcesDescriptionContainer.AddChild(control);
+ }
+
+ #endregion
+
+ #region Composition
+
+ foreach (var (reagent, quantity) in composition)
+ {
+ var control = new GuideFoodComposition(reagent, quantity);
+ CompositionDescriptionContainer.AddChild(control);
+ }
+
+ #endregion
+
+ FormattedMessage description = new();
+ description.AddText(proto?.Description ?? string.Empty);
+ // Cannot describe food flavor or smth beause food is entirely server-side
+
+ FoodDescription.SetMessage(description);
+ }
+
+ private void CalculateColors(List<(ReagentPrototype, FixedPoint2)> composition, out Color text, out Color background)
+ {
+ // Background color is calculated as the weighted average of the colors of the composition.
+ // Text color is determined based on background luminosity.
+ float r = 0, g = 0, b = 0;
+ FixedPoint2 weight = 0;
+
+ foreach (var (proto, quantity) in composition)
+ {
+ var tcolor = proto.SubstanceColor;
+ var prevalence =
+ quantity <= 0 ? 0f
+ : weight == 0f ? 1f
+ : (quantity / (weight + quantity)).Float();
+
+ r = r * (1 - prevalence) + tcolor.R * prevalence;
+ g = g * (1 - prevalence) + tcolor.G * prevalence;
+ b = b * (1 - prevalence) + tcolor.B * prevalence;
+
+ if (quantity > 0)
+ weight += quantity;
+ }
+
+ // Copied from GuideReagentEmbed which was probably copied from stackoverflow. This is the formula for color luminosity.
+ var lum = 0.2126f * r + 0.7152f * g + 0.0722f;
+
+ background = new Color(r, g, b);
+ text = lum > 0.5f ? Color.Black : Color.White;
+ }
+}
diff --git a/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml b/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml
new file mode 100644
index 00000000000..da671adaa72
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml.cs
new file mode 100644
index 00000000000..0e1034e3943
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodGroupEmbed.xaml.cs
@@ -0,0 +1,38 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Content.Client.Guidebook.Richtext;
+using Content.Client.Nutrition.EntitySystems;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Guidebook.Controls;
+
+[UsedImplicitly, GenerateTypedNameReferences]
+public sealed partial class GuideFoodGroupEmbed : BoxContainer, IDocumentTag
+{
+ [Dependency] private readonly IEntitySystemManager _sysMan = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+
+ public GuideFoodGroupEmbed()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ MouseFilter = MouseFilterMode.Stop;
+
+ foreach (var data in _sysMan.GetEntitySystem().Registry.OrderBy(it => it.Identifier))
+ {
+ var embed = new GuideFoodEmbed(data);
+ GroupContainer.AddChild(embed);
+ }
+ }
+
+ public bool TryParseTag(Dictionary args, [NotNullWhen(true)] out Control? control)
+ {
+ control = this;
+ return true;
+ }
+}
diff --git a/Content.Client/Guidebook/Controls/GuideFoodSource.xaml b/Content.Client/Guidebook/Controls/GuideFoodSource.xaml
new file mode 100644
index 00000000000..74e3a2ec30d
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodSource.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Guidebook/Controls/GuideFoodSource.xaml.cs b/Content.Client/Guidebook/Controls/GuideFoodSource.xaml.cs
new file mode 100644
index 00000000000..b0fee709759
--- /dev/null
+++ b/Content.Client/Guidebook/Controls/GuideFoodSource.xaml.cs
@@ -0,0 +1,160 @@
+using System.Linq;
+using Content.Client.Chemistry.EntitySystems;
+using Content.Client.UserInterface.ControlExtensions;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.FixedPoint;
+using Content.Shared.Nutrition.Components;
+using JetBrains.Annotations;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Guidebook.Controls;
+
+[UsedImplicitly, GenerateTypedNameReferences]
+public sealed partial class GuideFoodSource : BoxContainer, ISearchableControl
+{
+ private readonly IPrototypeManager _protoMan;
+ private readonly SpriteSystem _sprites = default!;
+
+ public GuideFoodSource(IPrototypeManager protoMan)
+ {
+ RobustXamlLoader.Load(this);
+ _protoMan = protoMan;
+ _sprites = IoCManager.Resolve().GetEntitySystem();
+ }
+
+ public GuideFoodSource(EntityPrototype result, FoodSourceData entry, IPrototypeManager protoMan) : this(protoMan)
+ {
+ switch (entry)
+ {
+ case FoodButcheringData butchering:
+ GenerateControl(butchering);
+ break;
+ case FoodSlicingData slicing:
+ GenerateControl(slicing);
+ break;
+ case FoodRecipeData recipe:
+ GenerateControl(recipe);
+ break;
+ case FoodReactionData reaction:
+ GenerateControl(reaction);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(entry), entry, null);
+ }
+
+ GenerateOutputs(result, entry);
+ }
+
+ private void GenerateControl(FoodButcheringData entry)
+ {
+ if (!_protoMan.TryIndex(entry.Butchered, out var ent))
+ {
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-unknown-proto", ("id", entry.Butchered)));
+ return;
+ }
+
+ SetSource(ent);
+ ProcessingLabel.Text = Loc.GetString("guidebook-food-processing-butchering");
+
+ ProcessingTexture.Texture = entry.Type switch
+ {
+ ButcheringType.Knife => GetRsiTexture("/Textures/Objects/Weapons/Melee/kitchen_knife.rsi", "icon"),
+ _ => GetRsiTexture("/Textures/Structures/meat_spike.rsi", "spike")
+ };
+ }
+
+ private void GenerateControl(FoodSlicingData entry)
+ {
+ if (!_protoMan.TryIndex(entry.Sliced, out var ent))
+ {
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-unknown-proto", ("id", entry.Sliced)));
+ return;
+ }
+
+ SetSource(ent);
+ ProcessingLabel.Text = Loc.GetString("guidebook-food-processing-slicing");
+ ProcessingTexture.Texture = GetRsiTexture("/Textures/Objects/Misc/utensils.rsi", "plastic_knife");
+ }
+
+ private void GenerateControl(FoodRecipeData entry)
+ {
+ if (!_protoMan.TryIndex(entry.Recipe, out var recipe))
+ {
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-unknown-proto", ("id", entry.Result)));
+ return;
+ }
+
+ var combinedSolids = recipe.IngredientsSolids
+ .Select(it => _protoMan.TryIndex(it.Key, out var proto) ? FormatIngredient(proto, it.Value) : "")
+ .Where(it => it.Length > 0);
+ var combinedLiquids = recipe.IngredientsReagents
+ .Select(it => _protoMan.TryIndex(it.Key, out var proto) ? FormatIngredient(proto, it.Value) : "")
+ .Where(it => it.Length > 0);
+
+ var combinedIngredients = string.Join("\n", combinedLiquids.Union(combinedSolids));
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-processing-recipe", ("ingredients", combinedIngredients)));
+
+ ProcessingTexture.Texture = GetRsiTexture("/Textures/Structures/Machines/microwave.rsi", "mw");
+ ProcessingLabel.Text = Loc.GetString("guidebook-food-processing-cooking", ("time", recipe.CookTime));
+ }
+
+ private void GenerateControl(FoodReactionData entry)
+ {
+ if (!_protoMan.TryIndex(entry.Reaction, out var reaction))
+ {
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-unknown-proto", ("id", entry.Reaction)));
+ return;
+ }
+
+ var combinedReagents = reaction.Reactants
+ .Select(it => _protoMan.TryIndex(it.Key, out var proto) ? FormatIngredient(proto, it.Value.Amount) : "")
+ .Where(it => it.Length > 0);
+
+ SourceLabel.SetMessage(Loc.GetString("guidebook-food-processing-recipe", ("ingredients", string.Join("\n", combinedReagents))));
+ ProcessingTexture.TexturePath = "/Textures/Interface/Misc/beakerlarge.png";
+ ProcessingLabel.Text = Loc.GetString("guidebook-food-processing-reaction");
+ }
+
+ private Texture GetRsiTexture(string path, string state)
+ {
+ return _sprites.Frame0(new SpriteSpecifier.Rsi(new ResPath(path), state));
+ }
+
+ private void GenerateOutputs(EntityPrototype result, FoodSourceData entry)
+ {
+ OutputsLabel.Text = Loc.GetString("guidebook-food-output", ("name", result.Name), ("number", entry.OutputCount));
+ OutputsTexture.Texture = _sprites.Frame0(result);
+ }
+
+ private void SetSource(EntityPrototype ent)
+ {
+ SourceLabel.SetMessage(ent.Name);
+ OutputsTexture.Texture = _sprites.Frame0(ent);
+ }
+
+ private string FormatIngredient(EntityPrototype proto, FixedPoint2 amount)
+ {
+ return Loc.GetString("guidebook-food-ingredient-solid", ("name", proto.Name), ("amount", amount));
+ }
+
+ private string FormatIngredient(ReagentPrototype proto, FixedPoint2 amount)
+ {
+ return Loc.GetString("guidebook-food-ingredient-liquid", ("name", proto.LocalizedName), ("amount", amount));
+ }
+
+ public bool CheckMatchesSearch(string query)
+ {
+ return this.ChildrenContainText(query);
+ }
+
+ public void SetHiddenState(bool state, string query)
+ {
+ Visible = CheckMatchesSearch(query) ? state : !state;
+ }
+}
diff --git a/Content.Client/Nutrition/EntitySystems/FoodGuideDataSystem.cs b/Content.Client/Nutrition/EntitySystems/FoodGuideDataSystem.cs
new file mode 100644
index 00000000000..37c7a25e219
--- /dev/null
+++ b/Content.Client/Nutrition/EntitySystems/FoodGuideDataSystem.cs
@@ -0,0 +1,30 @@
+using Content.Client.Chemistry.EntitySystems;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Nutrition.EntitySystems;
+
+public sealed class FoodGuideDataSystem : SharedFoodGuideDataSystem
+{
+ public override void Initialize()
+ {
+ SubscribeNetworkEvent(OnReceiveRegistryUpdate);
+ }
+
+ private void OnReceiveRegistryUpdate(FoodGuideRegistryChangedEvent message)
+ {
+ Registry = message.Changeset;
+ }
+
+ public bool TryGetData(EntProtoId result, out FoodGuideEntry entry)
+ {
+ var index = Registry.FindIndex(it => it.Result == result);
+ if (index == -1)
+ {
+ entry = default;
+ return false;
+ }
+
+ entry = Registry[index];
+ return true;
+ }
+}
diff --git a/Content.Server/Nutrition/EntitySystems/FoodGuideDataSystem.cs b/Content.Server/Nutrition/EntitySystems/FoodGuideDataSystem.cs
new file mode 100644
index 00000000000..f21c509ace2
--- /dev/null
+++ b/Content.Server/Nutrition/EntitySystems/FoodGuideDataSystem.cs
@@ -0,0 +1,138 @@
+using System.Linq;
+using Content.Client.Chemistry.EntitySystems;
+using Content.Server.Chemistry.ReactionEffects;
+using Content.Server.Nutrition.Components;
+using Content.Shared.Chemistry.Components.SolutionManager;
+using Content.Shared.Chemistry.Reaction;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.Kitchen;
+using Content.Shared.Nutrition.Components;
+using Robust.Server.Player;
+using Robust.Shared.Enums;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Nutrition.EntitySystems;
+
+public sealed class FoodGuideDataSystem : SharedFoodGuideDataSystem
+{
+ public static readonly ProtoId[] ReagentWhitelist =
+ [
+ "Nutriment",
+ "Vitamin",
+ "Protein",
+ "UncookedAnimalProteins",
+ "Fat",
+ "Water"
+ ];
+
+ public static readonly string[] ComponentNamesBlacklist = ["HumanoidAppearance"];
+
+ public static readonly string[] SuffixBlacklist = ["debug", "do not map", "admeme"];
+
+ [Dependency] private readonly IPlayerManager _player = default!;
+ [Dependency] private readonly IPrototypeManager _protoMan = default!;
+
+ private Dictionary> _sources = new();
+
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnPrototypesReloaded);
+ _player.PlayerStatusChanged += OnPlayerStatusChanged;
+
+ ReloadRecipes();
+ }
+
+ private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
+ {
+ if (!args.WasModified()
+ && !args.WasModified()
+ && !args.WasModified()
+ )
+ return;
+
+ ReloadRecipes();
+ }
+
+ public void ReloadRecipes()
+ {
+ // TODO: add this code to the list of known recipes because this is spaghetti
+ _sources.Clear();
+
+ // Butcherable and slicable entities
+ foreach (var ent in _protoMan.EnumeratePrototypes())
+ {
+ if (ent.Abstract
+ || ent.Components.Any(it => ComponentNamesBlacklist.Contains(it.Key))
+ || ent.SetSuffix is {} suffix && SuffixBlacklist.Any(it => suffix.Contains(it, StringComparison.OrdinalIgnoreCase))
+ )
+ continue;
+
+ if (ent.TryGetComponent(out var butcherable))
+ {
+ var butcheringSource = new FoodButcheringData(ent, butcherable);
+ foreach (var butchlet in butcherable.SpawnedEntities)
+ {
+ if (butchlet.PrototypeId is null)
+ continue;
+
+ _sources.GetOrNew(butchlet.PrototypeId).Add(butcheringSource);
+ }
+ }
+
+ if (ent.TryGetComponent(out var slicable) && slicable.Slice is not null)
+ {
+ _sources.GetOrNew(slicable.Slice).Add(new FoodSlicingData(ent, slicable.Slice.Value, slicable.TotalCount));
+ }
+ }
+
+ // Recipes
+ foreach (var recipe in _protoMan.EnumeratePrototypes())
+ {
+ _sources.GetOrNew(recipe.Result).Add(new FoodRecipeData(recipe));
+ }
+
+ // Entity-spawning reactions
+ foreach (var reaction in _protoMan.EnumeratePrototypes())
+ {
+ foreach (var effect in reaction.Effects)
+ {
+ if (effect is not CreateEntityReactionEffect entEffect)
+ continue;
+
+ _sources.GetOrNew(entEffect.Entity).Add(new FoodReactionData(reaction, entEffect.Entity, (int) entEffect.Number));
+ }
+ }
+
+ Registry.Clear();
+
+ foreach (var (result, sources) in _sources)
+ {
+ var proto = _protoMan.Index(result);
+ var composition = proto.TryGetComponent(out var food) && proto.TryGetComponent(out var manager)
+ ? manager?.Solutions?[food.Solution]?.Contents?.ToArray() ?? []
+ : [];
+
+ // We filter out food without whitelisted reagents because well when people look for food they usually expect FOOD and not insulated gloves.
+ // And we get insulated and other gloves because they have ButcherableComponent and they are also moth food
+ if (!composition.Any(it => ReagentWhitelist.Contains(it.Reagent.Prototype)))
+ continue;
+
+ // We also limit the number of sources to 10 because it's a huge performance strain to render 500 raw meat recipes.
+ var distinctSources = sources.DistinctBy(it => it.Identitier).Take(10);
+ var entry = new FoodGuideEntry(result, proto.Name, distinctSources.ToArray(), composition);
+ Registry.Add(entry);
+ }
+
+ RaiseNetworkEvent(new FoodGuideRegistryChangedEvent(Registry));
+ }
+
+ private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs args)
+ {
+ if (args.NewStatus != SessionStatus.Connected)
+ return;
+
+ RaiseNetworkEvent(new FoodGuideRegistryChangedEvent(Registry), args.Session);
+ }
+}
diff --git a/Content.Shared/Nutrition/Components/ButcherableComponent.cs b/Content.Shared/Nutrition/Components/ButcherableComponent.cs
index 4fce45422ad..975d4329dc5 100644
--- a/Content.Shared/Nutrition/Components/ButcherableComponent.cs
+++ b/Content.Shared/Nutrition/Components/ButcherableComponent.cs
@@ -1,5 +1,6 @@
using Content.Shared.Storage;
using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
namespace Content.Shared.Nutrition.Components
{
@@ -25,6 +26,7 @@ public sealed partial class ButcherableComponent : Component
public bool BeingButchered;
}
+ [Serializable, NetSerializable]
public enum ButcheringType : byte
{
Knife, // e.g. goliaths
diff --git a/Content.Shared/Nutrition/EntitySystems/SharedFoodGuideDataSystem.cs b/Content.Shared/Nutrition/EntitySystems/SharedFoodGuideDataSystem.cs
new file mode 100644
index 00000000000..c31776cd756
--- /dev/null
+++ b/Content.Shared/Nutrition/EntitySystems/SharedFoodGuideDataSystem.cs
@@ -0,0 +1,158 @@
+using System.Linq;
+using Content.Shared.Chemistry.Reaction;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.Kitchen;
+using Content.Shared.Nutrition.Components;
+using Content.Shared.Storage;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Client.Chemistry.EntitySystems;
+
+public abstract class SharedFoodGuideDataSystem : EntitySystem
+{
+ public List Registry = new();
+}
+
+[Serializable, NetSerializable]
+public sealed class FoodGuideRegistryChangedEvent : EntityEventArgs
+{
+ [DataField]
+ public List Changeset;
+
+ public FoodGuideRegistryChangedEvent(List changeset)
+ {
+ Changeset = changeset;
+ }
+}
+
+[DataDefinition, Serializable, NetSerializable]
+public partial struct FoodGuideEntry
+{
+ [DataField]
+ public EntProtoId Result;
+
+ [DataField]
+ public string Identifier; // Used for sorting
+
+ [DataField]
+ public FoodSourceData[] Sources;
+
+ [DataField]
+ public ReagentQuantity[] Composition;
+
+ public FoodGuideEntry(EntProtoId result, string identifier, FoodSourceData[] sources, ReagentQuantity[] composition)
+ {
+ Result = result;
+ Identifier = identifier;
+ Sources = sources;
+ Composition = composition;
+ }
+}
+
+[ImplicitDataDefinitionForInheritors, Serializable, NetSerializable]
+public abstract partial class FoodSourceData
+{
+ ///
+ /// Number of products created from this source. Used for primary ordering.
+ ///
+ public abstract int OutputCount { get; }
+
+ ///
+ /// A string used to distinguish different sources. Typically the name of the related entity.
+ ///
+ public string Identitier;
+
+ public abstract bool IsSourceOf(EntProtoId food);
+}
+
+[Serializable, NetSerializable]
+public sealed partial class FoodButcheringData : FoodSourceData
+{
+ [DataField]
+ public EntProtoId Butchered;
+
+ [DataField]
+ public ButcheringType Type;
+
+ [DataField]
+ public List Results;
+
+ public override int OutputCount => Results.Count;
+
+ public FoodButcheringData(EntityPrototype butchered, ButcherableComponent comp)
+ {
+ Identitier = butchered.Name;
+ Butchered = butchered.ID;
+ Type = comp.Type;
+ Results = comp.SpawnedEntities;
+ }
+
+ public override bool IsSourceOf(EntProtoId food) => Results.Any(it => it.PrototypeId == food);
+}
+
+[Serializable, NetSerializable]
+public sealed partial class FoodSlicingData : FoodSourceData
+{
+ [DataField]
+ public EntProtoId Sliced, Result;
+
+ [DataField]
+ private int _outputCount;
+ public override int OutputCount => _outputCount;
+
+ public FoodSlicingData(EntityPrototype sliced, EntProtoId result, int outputCount)
+ {
+ Identitier = sliced.Name;
+ Sliced = sliced.ID;
+ Result = result;
+ _outputCount = outputCount; // Server-only
+ }
+
+ public override bool IsSourceOf(EntProtoId food) => food == Result;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class FoodRecipeData : FoodSourceData
+{
+ [DataField]
+ public ProtoId Recipe;
+
+ [DataField]
+ public EntProtoId Result;
+
+ public override int OutputCount => 1;
+
+ public FoodRecipeData(FoodRecipePrototype proto)
+ {
+ Identitier = proto.Name;
+ Recipe = proto.ID;
+ Result = proto.Result;
+ }
+
+ public override bool IsSourceOf(EntProtoId food) => food == Result;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class FoodReactionData : FoodSourceData
+{
+ [DataField]
+ public ProtoId Reaction;
+
+ [DataField]
+ public EntProtoId Result;
+
+ [DataField]
+ private int _outputCount;
+ public override int OutputCount => _outputCount;
+
+ public FoodReactionData(ReactionPrototype reaction, EntProtoId result, int outputCount)
+ {
+ Identitier = reaction.Name;
+ Reaction = reaction.ID;
+ Result = result;
+ _outputCount = outputCount;
+ }
+
+ public override bool IsSourceOf(EntProtoId food) => food == Result;
+}
diff --git a/Resources/Locale/en-US/guidebook/food.ftl b/Resources/Locale/en-US/guidebook/food.ftl
new file mode 100644
index 00000000000..bd4fa032516
--- /dev/null
+++ b/Resources/Locale/en-US/guidebook/food.ftl
@@ -0,0 +1,16 @@
+guidebook-food-name = [bold][color={$color}]{CAPITALIZE($name)}[/color][/bold]
+guidebook-food-unknown-proto = Unknown prototype
+guidebook-food-sources-header = Sources
+guidebook-food-sources-ent-wrapper = {$name}
+guidebook-food-reagents-header = Chemical composition
+
+guidebook-food-processing-butchering = Butcher
+guidebook-food-processing-slicing = Slice
+guidebook-food-processing-cooking = Microwave for {$time}s
+guidebook-food-processing-reaction = Mix
+
+guidebook-food-processing-recipe = {$ingredients}
+guidebook-food-ingredient-solid = add {$amount} {$name}
+guidebook-food-ingredient-liquid = add {$amount}u {$name}
+
+guidebook-food-output = {$name} ({$number})
diff --git a/Resources/Prototypes/Guidebook/shiftandcrew.yml b/Resources/Prototypes/Guidebook/shiftandcrew.yml
index 3c4618902e6..66f9e7316dc 100644
--- a/Resources/Prototypes/Guidebook/shiftandcrew.yml
+++ b/Resources/Prototypes/Guidebook/shiftandcrew.yml
@@ -41,3 +41,4 @@
id: Food Recipes
name: guide-entry-foodrecipes
text: "/ServerInfo/Guidebook/Service/FoodRecipes.xml"
+ filterEnabled: true
diff --git a/Resources/ServerInfo/Guidebook/Service/FoodRecipes.xml b/Resources/ServerInfo/Guidebook/Service/FoodRecipes.xml
index 797591dd783..c74b947dbf6 100644
--- a/Resources/ServerInfo/Guidebook/Service/FoodRecipes.xml
+++ b/Resources/ServerInfo/Guidebook/Service/FoodRecipes.xml
@@ -1,88 +1,9 @@
-## Starting Out
-This is not an extensive list of recipes, these listings are to showcase the basics.
+## Recipe list
+Note: Only solid foods are listed here! To learn recipes for liquid ingredients, check the chemistry guidebook.
-Mixes are done in a Beaker, foods are cooked in a Microwave. Cook times will be listed.
+This list is auto-generated and contains all known foods.
-WARNING: This is not an automatically generated list, things here may become outdated. The wiki has much more than is listed here.
-
-## The Basics: Mixing
-
-- Dough = 15 Flour, 10 Water
-- Cornmeal Dough = 1 Egg (6u), 10 Milk, 15 Cornmeal
-- Tortila Dough = 15 Cornmeal, 10 Water
-- Tofu = 5 Enzyme (Catalyst), 30 Soy Milk
-- Pie Dough = 2 Eggs (12u), 15 Flour, 5 Table Salt
-- Cake Batter = 2 Eggs(12u), 15 flour, 5 Sugar
-- Vegan Cake Batter = 15 Soy Milk, 15 Flour, 5 Sugar
-- Butter = 30 Milk, 5 Table Salt (Catalyst)
-- Cheese Wheel = 5 Enzyme (Catalyst), 40 Milk
-- Chèvre Log = 5 Enzyme (Catalyst), 10 Goat Milk
-- Meatball = 1 Egg (6u), 5 Flour, 5 Uncooked Animal Proteins
-- Chocolate = 6 Cocoa Powder, 2 Milk, 2 Sugar
-- Uncooked Animal Protein: Grind Raw Meat
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-## Secondary Products
-
-- Dough Slice: Cut Dough
-- Bun: Microwave Dough Slice for 5 Seconds
-- Cutlet: Slice Raw Meat
-- Cheese Wedge: Slice Cheese Wheel
-- Flat Dough: Use a rolling pin or a round object (fire extinguisher, soda can, bottle) on Dough.
-- Tortilla Dough Slice: cut Tortilla Dough
-- Flat Tortilla Dough: Use a rolling pin or a round object (fire extinguisher, soda can, bottle) on Tortilla Dough Slice
-- Taco Shell: Microwave Flat Tortilla Dough for 5 Seconds
-
-## Food Examples
-
-- Bread: Microwave Dough for 10 Seconds
-- Plain Burger: Microwave 1 Bun and 1 Raw Meat for 10 Seconds
-- Tomato Soup: 10u Water, 1 Bowl, and 2 Tomatoes for 10 Seconds
-- Citrus Salad: 1 Bowl, 1 Lemon, 1 Lime, 1 Orange for 5 Seconds
-- Margherita Pizza: Microwave 1 Flat Dough, 1 Cheese Wedge, and 4 Tomatoes for 30 Seconds
-- Cake: 1 Cake Batter for 15 Seconds
-- Apple Pie: 1 Pie Dough, 3 Apples, and 1 Pie Tin for 15 Seconds
-- Beef Taco: Microwave 1 Taco Shell, 1 Raw Meat Cutlet, 1 Cheese Wedge for 10 Seconds
-- Cuban Carp : Microwave 1 Dough, 1 Cheese Wedge, 1 Chili, 1 Carp Meat for 15 Seconds
-- Banana Cream Pie : Microwave 1 Pie Dough, 3 Bananas, and 1 Pie Tin for 15 Seconds
-- Carrot Fries : Microwave 1 Carrot, 15u Salt for 15 Seconds
-- Pancake : Microwave 5u Flour, 5u Milk, 1 Egg (6u) for 5 Seconds
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+