using System.Diagnostics.CodeAnalysis; using Content.Shared.Botany.Components; using Content.Shared.Chemistry.EntitySystems; using Content.Shared.EntityEffects; using Content.Shared.Examine; using Content.Shared.FixedPoint; using Content.Shared.Interaction; using Content.Shared.Random.Helpers; using JetBrains.Annotations; using Robust.Shared.Audio.Systems; using Robust.Shared.Containers; using Robust.Shared.Timing; namespace Content.Shared.Botany.Systems; /// /// Handles plant tray state, including plant management, resource consumption, /// reagent processing, and periodic tray updates. /// public sealed partial class PlantTraySystem : EntitySystem { [Dependency] private IGameTiming _timing = default!; [Dependency] private PlantHolderSystem _plantHolder = default!; [Dependency] private PlantSystem _plant = default!; [Dependency] private SharedAudioSystem _audio = default!; [Dependency] private SharedEntityEffectsSystem _entityEffects = default!; [Dependency] private SharedInteractionSystem _interaction = default!; [Dependency] private SharedSolutionContainerSystem _solutionContainer = default!; [Dependency] private SharedTransformSystem _transform = default!; [Dependency] private EntityQuery _trayQuery; [Dependency] private EntityQuery _dataQuery; [SubscribeLocalEvent] private void OnExamine(Entity ent, ref ExaminedEvent args) { if (!args.IsInDetailsRange) return; using (args.PushGroup(nameof(PlantTrayComponent))) { if (!TryGetPlant(ent.AsNullable(), out var plantUid)) { args.PushMarkup(Loc.GetString("tray-component-nothing-planted-message")); if (_dataQuery.TryComp(plantUid, out var plantData)) { var name = Loc.GetString(plantData.Name); args.PushMarkup(Loc.GetString("plant-component-something-already-growing-message", ("seedName", name))); } } args.PushMarkup(Loc.GetString("tray-component-water-level-message", ("waterLevel", (int)ent.Comp.WaterLevel))); args.PushMarkup(Loc.GetString("tray-component-nutrient-level-message", ("nutritionLevel", (int)ent.Comp.NutritionLevel))); args.PushMarkup(GetTrayWarningsMarkup(ent.AsNullable())); if (plantUid != null && ent.Comp.DrawWarnings) args.PushMarkup(_plant.GetPlantWarningsMarkup(plantUid.Value)); } } [SubscribeLocalEvent] private void OnSolutionTransferred(Entity ent, ref SolutionTransferredEvent args) { _audio.PlayPredicted(ent.Comp.WateringSound, ent, args.User); } // TODO: replace with relationship system when it will be merged. // this is really cursed - handler is getting hits on client when plant getting into tray // but not when it gets out, yet it works just fine on server. [SubscribeLocalEvent] private void OnPlantTerminating(Entity ent, ref EntityTerminatingEvent args) { var trayUid = Transform(ent).ParentUid; if (!_trayQuery.TryComp(trayUid, out var tray) || tray.PlantEntity != ent.Owner) { return; } tray.PlantEntity = null; DirtyField(trayUid, tray, nameof(tray.PlantEntity)); } // Workaround for https://github.com/space-wizards/space-station-14/pull/35314 [SubscribeLocalEvent] private void OnEntRemoved(Entity ent, ref EntRemovedFromContainerMessage args) { // Make sure the removed entity was our contained solution and clear our cached reference if (args.Entity == ent.Comp.SoilSolution?.Owner) ent.Comp.SoilSolution = null; } [SubscribeLocalEvent] private void OnRelayInteractUsing(Entity ent, ref PlantHolderRelayedEvent args) { if (args.Args.Handled) return; args.Args.Handled = _interaction.InteractUsing(args.Args.User, args.Args.Used, ent, args.Args.ClickLocation); } /// /// Updates trays whose periodic processing is due. /// public override void Update(float frameTime) { base.Update(frameTime); var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var tray)) { if (tray.NextUpdate > _timing.CurTime) continue; tray.NextUpdate = _timing.CurTime + tray.UpdateDelay; DirtyField(uid, tray, nameof(tray.NextUpdate)); UpdateReagents(uid); GrowthWeeds(uid); var ev = new TrayUpdateEvent(); RaiseLocalEvent(uid, ref ev); } } /// /// Updates the reagents of the tray. /// [PublicAPI] public void UpdateReagents(Entity ent) { var (trayUid, trayComp) = ent; if (!Resolve(trayUid, ref trayComp, false)) return; if (!_solutionContainer.ResolveSolution(trayUid, trayComp.SoilSolutionName, ref trayComp.SoilSolution, out var solution)) return; if (solution.Volume <= 0) return; TryGetPlant(ent, out var plantUid); var contents = trayComp.SoilSolution.Value.Comp.Solution.Contents.ToArray(); foreach (var entry in contents) { if (entry.Quantity.Float() >= 1f) { var reagentProto = ProtoMan.Index(entry.Reagent.Prototype); _entityEffects.ApplyEffects(trayUid, [.. reagentProto.PlantMetabolisms], entry.Quantity.Float()); if (plantUid != null) { _entityEffects.ApplyEffects(plantUid.Value, [.. reagentProto.PlantMetabolisms], entry.Quantity.Float()); } } } _solutionContainer.RemoveEachReagent(trayComp.SoilSolution.Value, FixedPoint2.New(1)); } private void GrowthWeeds(Entity ent) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return; if (ent.Comp is not { WaterLevel: > 10, NutritionLevel: > 5 }) return; if (SharedRandomExtensions.PredictedProb(_timing, ent.Comp.WeedGrowthChance, GetNetEntity(ent))) AdjustWeed(ent, ent.Comp.WeedGrowthAmount); } /// /// Planting a plant in a tray. /// [PublicAPI] public void PlantingPlantInTray(Entity trayEnt, EntityUid plantUid, float? healthOverride = null) { var (trayUid, trayComp) = trayEnt; if (!Resolve(trayUid, ref trayComp, false)) return; _plant.PlantingPlant(plantUid, healthOverride); _transform.SetCoordinates(plantUid, Transform(trayUid).Coordinates); _transform.SetParent(plantUid, trayUid); trayComp.PlantEntity = plantUid; DirtyField(trayEnt, nameof(trayComp.PlantEntity)); } /// /// Adjusts the nutrient level of the tray. /// [PublicAPI] public void AdjustNutrient(Entity ent, float amount) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return; ent.Comp.NutritionLevel += amount; ent.Comp.NutritionLevel = MathHelper.Clamp(ent.Comp.NutritionLevel, 0f, ent.Comp.MaxNutritionLevel); DirtyField(ent, nameof(ent.Comp.NutritionLevel)); } /// /// Adjusts the water level of the tray. /// [PublicAPI] public void AdjustWater(Entity ent, float amount) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return; ent.Comp.WaterLevel += amount; ent.Comp.WaterLevel = MathHelper.Clamp(ent.Comp.WaterLevel, 0f, ent.Comp.MaxWaterLevel); DirtyField(ent, nameof(ent.Comp.WaterLevel)); // Water dilutes toxins. if (amount > 0) AdjustToxin(ent, -amount * 4f); } /// /// Adjusts the pest level of the tray. /// [PublicAPI] public void AdjustPest(Entity ent, float amount) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return; ent.Comp.PestLevel = MathHelper.Clamp(ent.Comp.PestLevel + amount, 0f, ent.Comp.MaxPestLevel); DirtyField(ent, nameof(ent.Comp.PestLevel)); } /// /// Adjusts the toxin level of the tray. /// [PublicAPI] public void AdjustToxin(Entity ent, float amount) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return; ent.Comp.ToxinLevel = MathHelper.Clamp(ent.Comp.ToxinLevel + amount, 0f, ent.Comp.MaxToxinLevel); DirtyField(ent, nameof(ent.Comp.ToxinLevel)); } /// /// Adjusts the weed level of the tray. /// [PublicAPI] public void AdjustWeed(Entity ent, float amount) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return; ent.Comp.WeedLevel += amount * ent.Comp.WeedCoefficient; ent.Comp.WeedLevel = MathHelper.Clamp(ent.Comp.WeedLevel, 0f, ent.Comp.MaxWeedLevel); DirtyField(ent, nameof(ent.Comp.WeedLevel)); } /// /// Checks whether the tray's toxin level has reached half its maximum. /// [PublicAPI] public bool GetToxinThreshold(Entity ent) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return false; return ent.Comp.ToxinLevel >= ent.Comp.MaxToxinLevel * 0.5f; } /// /// Checks whether the tray contains a plant entity. /// public bool HasPlant(Entity ent) { return TryGetPlant(ent, out _); } /// /// Tries to get the plant entity in the tray. /// [PublicAPI] public bool TryGetPlant(Entity ent, [NotNullWhen(true)] out EntityUid? plant) { plant = null; if (!Resolve(ent.Owner, ref ent.Comp, false)) return false; plant = ent.Comp.PlantEntity; if (plant == null || Deleted(plant)) return false; return true; } /// /// Checks whether the tray contains a living plant entity. /// public bool HasAlivePlant(Entity ent) { return TryGetAlivePlant(ent, out _); } /// /// Tries to get the living plant entity in the tray. /// [PublicAPI] public bool TryGetAlivePlant(Entity ent, [NotNullWhen(true)] out EntityUid? plant) { plant = null; if (!Resolve(ent.Owner, ref ent.Comp)) return false; if (!TryGetPlant(ent.Owner, out plant)) return false; return !_plantHolder.IsDead(plant.Value); } /// /// Gets the warnings markup of the tray. /// [PublicAPI] public string GetTrayWarningsMarkup(Entity ent) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return string.Empty; var markup = new List(); if (GetWeedThreshold(ent)) markup.Add(Loc.GetString("tray-component-weed-high-level-warning")); if (GetWaterThreshold(ent)) markup.Add(Loc.GetString("tray-component-water-low-warning")); if (GetNutrientThreshold(ent)) markup.Add(Loc.GetString("tray-component-nutrient-low-warning")); if (GetToxinThreshold(ent)) markup.Add(Loc.GetString("tray-component-toxin-high-level-warning")); if (GetPestThreshold(ent)) markup.Add(Loc.GetString("tray-component-pest-high-level-warning")); return string.Join("\n", markup); } /// /// Checks whether the tray's weed level has reached half its maximum. /// [PublicAPI] public bool GetWeedThreshold(Entity ent) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return false; return ent.Comp.WeedLevel >= ent.Comp.MaxWeedLevel * 0.5f; } /// /// Checks whether the tray's pest level has reached half its maximum. /// [PublicAPI] public bool GetPestThreshold(Entity ent) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return false; return ent.Comp.PestLevel >= ent.Comp.MaxPestLevel * 0.5f; } /// /// Checks whether the tray's water level is critically low. /// [PublicAPI] public bool GetWaterThreshold(Entity ent) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return false; return ent.Comp.WaterLevel <= ent.Comp.MaxWaterLevel * 0.1f; } /// /// Checks whether the tray's nutrient level is critically low. /// [PublicAPI] public bool GetNutrientThreshold(Entity ent) { if (!Resolve(ent.Owner, ref ent.Comp, false)) return false; return ent.Comp.NutritionLevel <= ent.Comp.MaxNutritionLevel * 0.1f; } } /// /// Event raised when a tray is updated. /// [ByRefEvent] public readonly record struct TrayUpdateEvent;