using System.Linq;
using Content.Shared._DV.Kitchen;
using Content.Shared._DV.Kitchen.Components;
using Content.Shared._DV.Kitchen.Systems;
using Content.Shared.Audio;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.FixedPoint;
using Content.Shared.Nutrition.Components;
using Content.Shared.Popups;
using Content.Shared.Power;
using Content.Shared.Power.EntitySystems;
using Content.Shared.Throwing;
using Content.Shared.Trigger.Systems;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Server._DV.Kitchen;
public sealed class DeepFryerSystem : SharedDeepFryerSystem
{
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly SharedAmbientSoundSystem _ambientSound = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedContainerSystem _container = default!;
[Dependency] private readonly SharedPowerReceiverSystem _power = default!;
[Dependency] private readonly TriggerSystem _trigger = default!;
///
/// The trigger key used when non-frying oil reagents are added to the fryer
///
public const string WrongReagentTriggerKey = "reaction";
private readonly List _itemsToComplete = new();
private readonly List _itemsToBurn = new();
private readonly HashSet _processedItems = new();
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent(OnItemInserted);
SubscribeLocalEvent(OnItemRemoved);
SubscribeLocalEvent(OnPowerChanged);
SubscribeLocalEvent(OnSolutionTransferred);
SubscribeLocalEvent(OnThrowHitBy);
}
private void OnSolutionTransferred(Entity ent, ref SolutionTransferredEvent args)
{
// Only restore quality when oil is being added TO the fryer (not removed from it)
if (args.To != ent.Owner)
return;
// Get the fryer's solution to check what reagents are now in it
if (Solution.TryGetSolution(ent.Owner, ent.Comp.Solution, out _, out var solution))
{
// Check if any reagents in the solution are NOT valid frying oils
var hasInvalidReagent = false;
foreach (var reagent in solution.Contents)
{
if (!ent.Comp.FryingOils.Contains(reagent.Reagent.Prototype))
{
hasInvalidReagent = true;
break;
}
}
// If we found an invalid reagent, trigger the reaction
if (hasInvalidReagent)
{
_trigger.Trigger(ent, args.User, WrongReagentTriggerKey);
// Don't restore oil quality if we're triggering an explosion
return;
}
}
// Restore oil quality based on the amount transferred
var qualityRestored = (float)args.Amount * ent.Comp.OilQualityRestorationPerUnit;
ent.Comp.OilQuality = Math.Min(1.0f, ent.Comp.OilQuality + qualityRestored);
Dirty(ent);
}
private void OnPowerChanged(Entity ent, ref PowerChangedEvent args)
{
UpdateAppearance(ent);
}
private void OnThrowHitBy(Entity ent, ref ThrowHitByEvent args)
{
if (args.Component.Thrower is not { } thrower || !CanInsertItem(ent, args.Thrown, out _))
return;
if (!HasComp(thrower) && _random.Prob(ent.Comp.MissChance))
{
// Item missed! Let it continue with normal throw physics
Popup.PopupEntity(Loc.GetString("deep-fryer-throw-miss", ("item", args.Thrown)), ent, thrower);
return;
}
// Success! Insert the item
if (TryInsertItem(ent, args.Thrown, thrower))
Popup.PopupEntity(Loc.GetString("deep-fryer-throw-success", ("item", args.Thrown)), ent, thrower);
}
private void OnItemInserted(Entity ent, ref EntInsertedIntoContainerMessage args)
{
if (args.Container.ID != ent.Comp.ContainerName)
return;
if (!_container.TryGetContainer(ent, ent.Comp.ContainerName, out var container))
return;
// First, check if this new item completes any multi-ingredient recipes with items already in the fryer
var completedMultiRecipe = TryFindAndUpgradeToMultiRecipe(ent, container);
if (completedMultiRecipe == null)
{
// No multi-recipe was completed, so assign this item its best single-ingredient recipe
var singleRecipe = FindBestRecipeForItem(args.Entity);
ent.Comp.CookingItems[args.Entity] = new CookingItem(singleRecipe, _timing.CurTime);
}
UpdateAppearance(ent);
}
private void OnItemRemoved(Entity ent, ref EntRemovedFromContainerMessage args)
{
// Only process items in the fryer basket
if (args.Container.ID != ent.Comp.ContainerName)
return;
// Remove from cooking tracking
ent.Comp.CookingItems.Remove(args.Entity);
UpdateAppearance(ent);
}
///
/// Updates the visual appearance of the deep fryer based on power and cooking state
///
private void UpdateAppearance(Entity ent)
{
var isBubbling = false;
// Check if the fryer is powered and has items
if (_power.IsPowered(ent.Owner)
&& ent.Comp.CookingItems.Count > 0
&& HasEnoughOil(ent))
{
isBubbling = true;
}
UpdateAmbience(ent, isBubbling);
_appearance.SetData(ent, DeepFryerVisuals.Bubbling, isBubbling);
}
private void UpdateAmbience(Entity ent, bool value)
{
_ambientSound.SetAmbience(ent, value);
}
///
/// Finds the best recipe for a single item.
/// Prioritizes multi-ingredient recipes (returns null so item waits), then single-ingredient recipes.
/// Returns null if item is only in multi-ingredient recipes or has no recipe at all.
///
private ProtoId? FindBestRecipeForItem(EntityUid item)
{
var itemProto = MetaData(item).EntityPrototype?.ID;
if (itemProto == null)
return null;
ProtoId? singleIngredientRecipe = null;
// Look through all deep fryer recipes
foreach (var deepFryerRecipe in _prototype.EnumeratePrototypes())
{
// Get the base microwave recipe
if (!_prototype.Resolve(deepFryerRecipe.BaseRecipe, out var microwaveRecipe))
continue;
// Count total solid ingredients
FixedPoint2 totalIngredients = 0;
var hasThisItem = false;
foreach (var (ingredientId, count) in microwaveRecipe.IngredientsSolids)
{
totalIngredients += count;
if (ingredientId == itemProto)
hasThisItem = true;
}
if (!hasThisItem)
continue;
if (totalIngredients == 1)
{
// This is a single-ingredient recipe
singleIngredientRecipe = deepFryerRecipe.ID;
}
}
// Return the single-ingredient recipe (may be null)
return singleIngredientRecipe;
}
///
/// Checks if the newly inserted item completes any multi-ingredient recipe with existing items.
/// If so, upgrades all involved items to use that recipe.
/// Returns the recipe if one was found and upgraded, null otherwise.
///
private ProtoId? TryFindAndUpgradeToMultiRecipe(
Entity ent,
BaseContainer container)
{
// Look through all multi-ingredient recipes to see if any are now complete
foreach (var deepFryerRecipe in _prototype.EnumeratePrototypes())
{
// Get the base microwave recipe
if (!_prototype.Resolve(deepFryerRecipe.BaseRecipe, out var microwaveRecipe))
continue;
// Count total solid ingredients
FixedPoint2 totalIngredients = 0;
foreach (var (_, count) in microwaveRecipe.IngredientsSolids)
{
totalIngredients += count;
}
// Skip single-ingredient recipes
if (totalIngredients <= 1)
continue;
// Check if all ingredients for this multi-ingredient recipe are present
var ingredients = GetIngredientsForRecipe(deepFryerRecipe.ID, container);
if (ingredients == null)
continue;
// Check if ingredients are within tolerance
if (!AreIngredientsWithinTolerance(ent, ingredients))
continue;
// We found a complete multi-ingredient recipe within tolerance!
// Upgrade all ingredients to use this recipe
// Find the earliest start time among all ingredients
var earliestTime = _timing.CurTime;
foreach (var (ingredientUid, _) in ingredients)
{
if (ent.Comp.CookingItems.TryGetValue(ingredientUid, out var existingItem))
{
if (existingItem.TimeStarted < earliestTime)
earliestTime = existingItem.TimeStarted;
}
}
// Assign the multi-ingredient recipe to all ingredients with synchronized start time
foreach (var (ingredientUid, _) in ingredients)
{
ent.Comp.CookingItems[ingredientUid] = new CookingItem(deepFryerRecipe.ID, earliestTime);
}
return deepFryerRecipe.ID;
}
return null;
}
///
/// Tries to get all ingredients needed for a specific recipe from the fryer.
/// Returns null if not all ingredients are present.
///
private Dictionary? GetIngredientsForRecipe(
ProtoId recipeId,
BaseContainer container)
{
if (!_prototype.TryIndex(recipeId, out var deepFryerRecipe))
return null;
if (!_prototype.Resolve(deepFryerRecipe.BaseRecipe, out var microwaveRecipe))
return null;
var neededIngredients = new Dictionary();
foreach (var (ingredient, count) in microwaveRecipe.IngredientsSolids)
{
neededIngredients[ingredient] = count;
}
var foundIngredients = new Dictionary();
// Check each item in the fryer
foreach (var itemUid in container.ContainedEntities)
{
var itemProto = MetaData(itemUid).EntityPrototype?.ID;
if (itemProto == null)
continue;
// If this item is one of the needed ingredients
if (neededIngredients.TryGetValue(itemProto, out var needed) && needed > 0)
{
foundIngredients[itemUid] = itemProto;
neededIngredients[itemProto] -= 1;
}
}
// Check if we found all required ingredients
foreach (var (_, count) in neededIngredients)
{
if (count > 0)
return null; // Missing some ingredients
}
return foundIngredients;
}
///
/// Checks if all ingredients for a multi-ingredient recipe are within the cooking time tolerance
///
private bool AreIngredientsWithinTolerance(
Entity ent,
Dictionary ingredients)
{
if (ingredients.Count <= 1)
return true;
TimeSpan? earliest = null;
TimeSpan? latest = null;
foreach (var (ingredientUid, _) in ingredients)
{
if (!ent.Comp.CookingItems.TryGetValue(ingredientUid, out var cookingItem))
continue;
if (earliest == null || cookingItem.TimeStarted < earliest)
earliest = cookingItem.TimeStarted;
if (latest == null || cookingItem.TimeStarted > latest)
latest = cookingItem.TimeStarted;
}
if (earliest == null || latest == null)
return false;
var timeDifference = latest.Value - earliest.Value;
return timeDifference <= ent.Comp.CookingTolerance;
}
public override void Update(float frameTime)
{
base.Update(frameTime);
_itemsToComplete.Clear();
_itemsToBurn.Clear();
var curTime = _timing.CurTime;
var query = EntityQueryEnumerator();
while (query.MoveNext(out var uid, out var fryer))
{
// Skip if not powered
if (!_power.IsPowered(uid))
continue;
// Skip if no oil
if (!HasEnoughOil((uid, fryer)))
continue;
// Get the container
if (!_container.TryGetContainer(uid, fryer.ContainerName, out var container))
continue;
_processedItems.Clear();
// Process each cooking item
foreach (var (itemUid, cookingItem) in fryer.CookingItems.ToList())
{
// Skip if already processed as part of a multi-ingredient recipe
if (_processedItems.Contains(itemUid))
continue;
var elapsedTime = curTime - cookingItem.TimeStarted;
// If the item is already marked as burning
if (cookingItem.IsBurning)
{
if (cookingItem.Recipe is { } burningRecipe)
{
if (!_prototype.TryIndex(burningRecipe, out var deepFryerRecipe))
continue;
var burnTime = deepFryerRecipe.BurnTime;
if (elapsedTime >= burnTime)
{
_itemsToBurn.Add(itemUid);
}
}
continue;
}
// Item is cooking (not burning yet)
if (cookingItem.Recipe is { } recipe)
{
if (!_prototype.TryIndex(recipe, out var deepFryerRecipe))
continue;
if (!_prototype.Resolve(deepFryerRecipe.BaseRecipe, out var microwaveRecipe))
continue;
var cookTime = TimeSpan.FromSeconds(microwaveRecipe.CookTime);
// Check if this is part of a multi-ingredient recipe
var multiIngredients = GetIngredientsForRecipe(recipe, container);
if (multiIngredients is { Count: > 1 })
{
// This is a multi-ingredient recipe
// Check if all ingredients are still within tolerance
if (!AreIngredientsWithinTolerance((uid, fryer), multiIngredients))
continue;
// Find the earliest start time
var earliestStart = TimeSpan.MaxValue;
foreach (var (ingredientUid, _) in multiIngredients)
{
// TryGetValue in Update my beloved
// Only like one deep fryer per map so it's gonna be fine probably
if (!fryer.CookingItems.TryGetValue(ingredientUid, out var ingredientCookingItem))
continue;
if (ingredientCookingItem.TimeStarted < earliestStart)
earliestStart = ingredientCookingItem.TimeStarted;
}
// Check if enough time has passed since the earliest ingredient
var earliestElapsed = curTime - earliestStart;
if (earliestElapsed < cookTime)
continue;
{
// Mark the first item for completion (it will handle all ingredients)
_itemsToComplete.Add(itemUid);
// Mark all ingredients as processed
foreach (var (ingredientUid, _) in multiIngredients)
{
_processedItems.Add(ingredientUid);
}
}
// Note: If ingredients are outside tolerance, they keep their current recipes
// and will be handled individually (single-ingredient recipes will complete, items without recipes will burn)
}
else
{
// Single-ingredient recipe, proceed normally
if (elapsedTime >= cookTime)
{
_itemsToComplete.Add(itemUid);
}
}
}
else
{
// Item has no recipe assigned - it should burn after BaseBurnTime
if (elapsedTime >= fryer.BaseBurnTime)
{
_itemsToBurn.Add(itemUid);
}
}
}
// Complete cooking for finished items
foreach (var itemUid in _itemsToComplete)
{
CompleteCooking((uid, fryer), itemUid, container);
}
// Burn items that have been cooking too long or have no recipe
foreach (var itemUid in _itemsToBurn)
{
BurnItem((uid, fryer), itemUid, container);
}
}
}
///
/// Completes cooking for an item (or multi-ingredient recipe), transforming it into the result
///
private void CompleteCooking(Entity ent, EntityUid item, BaseContainer container)
{
if (!ent.Comp.CookingItems.TryGetValue(item, out var cookingItem))
return;
if (cookingItem.Recipe is not { } recipe)
return;
if (!_prototype.TryIndex(recipe, out var deepFryerRecipe))
return;
// Get the base microwave recipe for result
if (!_prototype.Resolve(deepFryerRecipe.BaseRecipe, out var microwaveRecipe))
return;
// Get all ingredients for this recipe
var recipeIngredients = GetIngredientsForRecipe(recipe, container);
var isMultiIngredient = recipeIngredients is { Count: > 1 };
// Check if we should burn the item due to foul oil
var qualityLevel = GetOilQualityLevel(ent.Comp.OilQuality);
if (qualityLevel == OilQuality.Foul && _random.Prob(ent.Comp.FoulOilBurnChance))
{
// For multi-ingredient recipes, burn all ingredients
if (isMultiIngredient)
{
foreach (var (ingredientUid, _) in recipeIngredients!)
{
BurnItem(ent, ingredientUid, container, recipe: recipe);
}
return;
}
// Force burn the single item
BurnItem(ent, item, container, recipe: recipe);
return;
}
var xform = Transform(ent);
var coords = Xform.GetMapCoordinates((ent, xform));
// For multi-ingredient recipes, remove ALL ingredients
if (isMultiIngredient)
{
// Delete all ingredients
foreach (var (ingredientUid, _) in recipeIngredients!)
{
ent.Comp.CookingItems.Remove(ingredientUid);
_container.Remove(ingredientUid, container);
QueueDel(ingredientUid);
}
}
else
{
// Single ingredient recipe
ent.Comp.CookingItems.Remove(item);
_container.Remove(item, container);
QueueDel(item);
}
// Spawn the result (from the microwave recipe)
var result = Spawn(microwaveRecipe.Result, coords);
// Transfer solution from fryer to food (includes oil AND any contaminants!)
TransferOilToFood(ent, result, deepFryerRecipe.OilConsumption);
// Add flavors based on oil quality
AddOilQualityFlavors(result, ent.Comp, qualityLevel);
// Degrade oil quality
DegradeOilQuality(ent);
// Try to put it back in the fryer
if (!_container.Insert(result, container))
{
// If we can't insert it (container full?), just leave it at the fryer's location
Xform.SetCoordinates(result, xform, Xform.GetMoverCoordinates(ent, xform));
}
else
{
// Track the result and start burning timer
ent.Comp.CookingItems[result] = new CookingItem(cookingItem.Recipe, _timing.CurTime, isBurning: true);
}
// Show a popup
Popup.PopupEntity(Loc.GetString("deep-fryer-item-finished", ("item", result)), ent, PopupType.Medium);
_audio.PlayPvs(ent.Comp.FinishedCookingSound, ent);
}
///
/// Burns an item - uses recipe's BurnedResult if available, otherwise uses BaseBurnedResult
///
private void BurnItem(Entity ent, EntityUid item, BaseContainer container, ProtoId? recipe = null)
{
EntProtoId? burnedEntity = null;
// If we were never explicitly given a recipe, then see if there's one
if (!recipe.HasValue && ent.Comp.CookingItems.TryGetValue(item, out var cookingItem) && cookingItem.Recipe is { } foundRecipe)
recipe = foundRecipe;
// Try to get the recipe, if we were given one or if we found one
if (_prototype.TryIndex(recipe, out var deepFryerRecipe))
burnedEntity = deepFryerRecipe.BurnedResult;
// finally, if we don't have a BurnedResult from a recipe, just default to BaseBurnedResult
if (!burnedEntity.HasValue)
burnedEntity = ent.Comp.BaseBurnedResult;
// Remove the item from tracking and container
ent.Comp.CookingItems.Remove(item);
_container.Remove(item, container);
// Delete the original item
QueueDel(item);
// Spawn the burned result on top of the fryer
Spawn(burnedEntity, Xform.GetMoverCoordinates(ent));
// Degrade oil quality even when burning
DegradeOilQuality(ent);
// Show a danger popup
Popup.PopupEntity(Loc.GetString("deep-fryer-item-burned", ("item", item)), ent, PopupType.MediumCaution);
_audio.PlayPvs(ent.Comp.FinishedBurningSound, ent);
}
///
/// Adds flavors to the cooked item based on the current oil quality
///
private void AddOilQualityFlavors(EntityUid result, DeepFryerComponent fryer, OilQuality qualityLevel)
{
// Get or create the FlavorProfile component
var flavorProfile = EnsureComp(result);
// Get the flavors for this quality level
if (!fryer.OilQualityFlavors.TryGetValue(qualityLevel, out var flavors))
return;
// Add each flavor to the profile
foreach (var flavor in flavors)
{
flavorProfile.Flavors.Add(flavor);
}
Dirty(result, flavorProfile);
}
///
/// Degrades the oil quality after cooking an item
///
private void DegradeOilQuality(Entity ent)
{
// Calculate degradation multiplier based on oil volume
var degradationMultiplier = CalculateOilDegradationMultiplier(ent);
// Reduce oil quality with the multiplier applied
var degradationAmount = ent.Comp.OilDegradationPerRecipe * degradationMultiplier;
ent.Comp.OilQuality = Math.Max(0f, ent.Comp.OilQuality - degradationAmount);
// Mark as dirty to sync to clients
Dirty(ent);
}
///
/// Transfers solution from the fryer into the food solution.
/// Transfers ALL reagents proportionally - if someone added bleach to the fryer, enjoy your bleach-fried food!
///
private void TransferOilToFood(Entity ent, EntityUid food, FixedPoint2 amount)
{
// Get the fryer's solution
if (!Solution.TryGetSolution(ent.Owner, ent.Comp.Solution, out _, out var fryerSolution))
return;
// Get the food solution
if (!Solution.TryGetSolution(food, "food", out var foodSolutionEnt, out _))
return;
// Split the desired amount from the fryer - this takes ALL reagents proportionally!
// If the fryer has 80% oil and 20% bleach, the food gets 80% oil and 20% bleach too!
var transferredSolution = fryerSolution.SplitSolution(amount);
// Add the split solution to the food
Solution.AddSolution(foodSolutionEnt.Value, transferredSolution);
}
}