diff --git a/Content.Client/Lathe/UI/LatheBoundUserInterface.cs b/Content.Client/Lathe/UI/LatheBoundUserInterface.cs index 4ddde885fa..75b1704b0d 100644 --- a/Content.Client/Lathe/UI/LatheBoundUserInterface.cs +++ b/Content.Client/Lathe/UI/LatheBoundUserInterface.cs @@ -30,6 +30,10 @@ namespace Content.Client.Lathe.UI { SendMessage(new LatheQueueRecipeMessage(recipe, amount)); }; + _menu.QueueDeleteAction += index => SendMessage(new LatheDeleteRequestMessage(index)); + _menu.QueueMoveUpAction += index => SendMessage(new LatheMoveRequestMessage(index, -1)); + _menu.QueueMoveDownAction += index => SendMessage(new LatheMoveRequestMessage(index, 1)); + _menu.DeleteFabricatingAction += () => SendMessage(new LatheAbortFabricationMessage()); } protected override void UpdateState(BoundUserInterfaceState state) diff --git a/Content.Client/Lathe/UI/LatheMenu.xaml b/Content.Client/Lathe/UI/LatheMenu.xaml index 28b79254c0..a5c8f6a85c 100644 --- a/Content.Client/Lathe/UI/LatheMenu.xaml +++ b/Content.Client/Lathe/UI/LatheMenu.xaml @@ -1,6 +1,7 @@ + diff --git a/Content.Client/Lathe/UI/LatheMenu.xaml.cs b/Content.Client/Lathe/UI/LatheMenu.xaml.cs index 66d875b0f2..ce190464d2 100644 --- a/Content.Client/Lathe/UI/LatheMenu.xaml.cs +++ b/Content.Client/Lathe/UI/LatheMenu.xaml.cs @@ -26,6 +26,10 @@ public sealed partial class LatheMenu : DefaultWindow public event Action? OnServerListButtonPressed; public event Action? RecipeQueueAction; + public event Action? QueueDeleteAction; + public event Action? QueueMoveUpAction; + public event Action? QueueMoveDownAction; + public event Action? DeleteFabricatingAction; public List> Recipes = new(); @@ -50,12 +54,21 @@ public sealed partial class LatheMenu : DefaultWindow }; AmountLineEdit.OnTextChanged += _ => { + if (int.TryParse(AmountLineEdit.Text, out var amount)) + { + if (amount > LatheSystem.MaxItemsPerRequest) + AmountLineEdit.Text = LatheSystem.MaxItemsPerRequest.ToString(); + else if (amount < 0) + AmountLineEdit.Text = "0"; + } + PopulateRecipes(); }; FilterOption.OnItemSelected += OnItemSelected; ServerListButton.OnPressed += a => OnServerListButtonPressed?.Invoke(a); + DeleteFabricating.OnPressed += _ => DeleteFabricatingAction?.Invoke(); } public void SetEntity(EntityUid uid) @@ -223,22 +236,27 @@ public sealed partial class LatheMenu : DefaultWindow /// Populates the build queue list with all queued items /// /// - public void PopulateQueueList(IReadOnlyCollection> queue) + public void PopulateQueueList(IReadOnlyCollection queue) { QueueList.DisposeAllChildren(); var idx = 1; - foreach (var recipeProto in queue) + foreach (var batch in queue) { - var recipe = _prototypeManager.Index(recipeProto); - var queuedRecipeBox = new BoxContainer(); - queuedRecipeBox.Orientation = BoxContainer.LayoutOrientation.Horizontal; + var recipe = _prototypeManager.Index(batch.Recipe); - queuedRecipeBox.AddChild(GetRecipeDisplayControl(recipe)); + var itemName = _lathe.GetRecipeName(batch.Recipe); + string displayText; + if (batch.ItemsRequested > 1) + displayText = Loc.GetString("lathe-menu-item-batch", ("index", idx), ("name", itemName), ("printed", batch.ItemsPrinted), ("total", batch.ItemsRequested)); + else + displayText = Loc.GetString("lathe-menu-item-single", ("index", idx), ("name", itemName)); + + var queuedRecipeBox = new QueuedRecipeControl(displayText, idx - 1, GetRecipeDisplayControl(recipe)); + queuedRecipeBox.OnDeletePressed += s => QueueDeleteAction?.Invoke(s); + queuedRecipeBox.OnMoveUpPressed += s => QueueMoveUpAction?.Invoke(s); + queuedRecipeBox.OnMoveDownPressed += s => QueueMoveDownAction?.Invoke(s); - var queuedRecipeLabel = new Label(); - queuedRecipeLabel.Text = $"{idx}. {_lathe.GetRecipeName(recipe)}"; - queuedRecipeBox.AddChild(queuedRecipeLabel); QueueList.AddChild(queuedRecipeBox); idx++; } diff --git a/Content.Client/Lathe/UI/QueuedRecipeControl.xaml b/Content.Client/Lathe/UI/QueuedRecipeControl.xaml new file mode 100644 index 0000000000..b1d4b496a1 --- /dev/null +++ b/Content.Client/Lathe/UI/QueuedRecipeControl.xaml @@ -0,0 +1,35 @@ + + + + + diff --git a/Content.Client/Lathe/UI/QueuedRecipeControl.xaml.cs b/Content.Client/Lathe/UI/QueuedRecipeControl.xaml.cs new file mode 100644 index 0000000000..c4ba9803b0 --- /dev/null +++ b/Content.Client/Lathe/UI/QueuedRecipeControl.xaml.cs @@ -0,0 +1,36 @@ +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.Lathe.UI; + +[GenerateTypedNameReferences] +public sealed partial class QueuedRecipeControl : Control +{ + public Action? OnDeletePressed; + public Action? OnMoveUpPressed; + public Action? OnMoveDownPressed; + + public QueuedRecipeControl(string displayText, int index, Control displayControl) + { + RobustXamlLoader.Load(this); + + RecipeName.Text = displayText; + RecipeDisplayContainer.AddChild(displayControl); + + MoveUp.OnPressed += (_) => + { + OnMoveUpPressed?.Invoke(index); + }; + + MoveDown.OnPressed += (_) => + { + OnMoveDownPressed?.Invoke(index); + }; + + Delete.OnPressed += (_) => + { + OnDeletePressed?.Invoke(index); + }; + } +} diff --git a/Content.Server/Lathe/LatheSystem.cs b/Content.Server/Lathe/LatheSystem.cs index 6ecd5bdf62..02abb07791 100644 --- a/Content.Server/Lathe/LatheSystem.cs +++ b/Content.Server/Lathe/LatheSystem.cs @@ -74,6 +74,9 @@ namespace Content.Server.Lathe SubscribeLocalEvent(OnLatheQueueRecipeMessage); SubscribeLocalEvent(OnLatheSyncRequestMessage); + SubscribeLocalEvent(OnLatheDeleteRequestMessage); + SubscribeLocalEvent(OnLatheMoveRequestMessage); + SubscribeLocalEvent(OnLatheAbortFabricationMessage); SubscribeLocalEvent((u, c, _) => UpdateUserInterfaceState(u, c)); SubscribeLocalEvent(OnMaterialAmountChanged); @@ -167,23 +170,32 @@ namespace Content.Server.Lathe return ev.Recipes.ToList(); } - public bool TryAddToQueue(EntityUid uid, LatheRecipePrototype recipe, LatheComponent? component = null) + public bool TryAddToQueue(EntityUid uid, LatheRecipePrototype recipe, int quantity, LatheComponent? component = null) { if (!Resolve(uid, ref component)) return false; - if (!CanProduce(uid, recipe, 1, component)) + if (quantity <= 0) + return false; + quantity = int.Min(quantity, MaxItemsPerRequest); + + if (!CanProduce(uid, recipe, quantity, component)) return false; foreach (var (mat, amount) in recipe.Materials) { var adjustedAmount = recipe.ApplyMaterialDiscount - ? (int) (-amount * component.MaterialUseMultiplier) + ? (int)(-amount * component.MaterialUseMultiplier) : -amount; + adjustedAmount *= quantity; _materialStorage.TryChangeMaterialAmount(uid, mat, adjustedAmount); } - component.Queue.Enqueue(recipe); + + if (component.Queue.Last is { } node && node.ValueRef.Recipe == recipe.ID) + node.ValueRef.ItemsRequested += quantity; + else + component.Queue.AddLast(new LatheRecipeBatch(recipe.ID, 0, quantity)); return true; } @@ -195,8 +207,11 @@ namespace Content.Server.Lathe if (component.CurrentRecipe != null || component.Queue.Count <= 0 || !this.IsPowered(uid, EntityManager)) return false; - var recipeProto = component.Queue.Dequeue(); - var recipe = _proto.Index(recipeProto); + var batch = component.Queue.First(); + batch.ItemsPrinted++; + if (batch.ItemsPrinted >= batch.ItemsRequested || batch.ItemsPrinted < 0) // Rollover sanity check + component.Queue.RemoveFirst(); + var recipe = _proto.Index(batch.Recipe); var time = _reagentSpeed.ApplySpeed(uid, recipe.CompleteTime) * component.TimeMultiplier; @@ -271,8 +286,8 @@ namespace Content.Server.Lathe return; var producing = component.CurrentRecipe; - if (producing == null && component.Queue.TryPeek(out var next)) - producing = next; + if (producing == null && component.Queue.First is { } node) + producing = node.Value.Recipe; var state = new LatheUpdateState(GetAvailableRecipes(uid, component), component.Queue.ToArray(), producing); _uiSys.SetUiState(uid, LatheUiKey.Key, state); @@ -349,12 +364,10 @@ namespace Content.Server.Lathe { if (!args.Powered) { - RemComp(uid); - UpdateRunningAppearance(uid, false); + AbortProduction(uid); } - else if (component.CurrentRecipe != null) + else { - EnsureComp(uid); TryStartProducing(uid, component); } } @@ -416,25 +429,46 @@ namespace Content.Server.Lathe return GetAvailableRecipes(uid, component).Contains(recipe.ID); } + public void AbortProduction(EntityUid uid, LatheComponent? component = null) + { + if (!Resolve(uid, ref component)) + return; + + if (component.CurrentRecipe != null) + { + if (component.Queue.Count > 0) + { + // Batch abandoned while printing last item, need to create a one-item batch + var batch = component.Queue.First(); + if (batch.Recipe != component.CurrentRecipe) + { + var newBatch = new LatheRecipeBatch(component.CurrentRecipe.Value, 0, 1); + component.Queue.AddFirst(newBatch); + } + else if (batch.ItemsPrinted > 0) + { + batch.ItemsPrinted--; + } + } + + component.CurrentRecipe = null; + } + RemCompDeferred(uid); + UpdateUserInterfaceState(uid, component); + UpdateRunningAppearance(uid, false); + } + #region UI Messages private void OnLatheQueueRecipeMessage(EntityUid uid, LatheComponent component, LatheQueueRecipeMessage args) { if (_proto.TryIndex(args.ID, out LatheRecipePrototype? recipe)) { - var count = 0; - for (var i = 0; i < args.Quantity; i++) - { - if (TryAddToQueue(uid, recipe, component)) - count++; - else - break; - } - if (count > 0) + if (TryAddToQueue(uid, recipe, args.Quantity, component)) { _adminLogger.Add(LogType.Action, LogImpact.Low, - $"{ToPrettyString(args.Actor):player} queued {count} {GetRecipeName(recipe)} at {ToPrettyString(uid):lathe}"); + $"{ToPrettyString(args.Actor):player} queued {args.Quantity} {GetRecipeName(recipe)} at {ToPrettyString(uid):lathe}"); } } TryStartProducing(uid, component); @@ -445,6 +479,92 @@ namespace Content.Server.Lathe { UpdateUserInterfaceState(uid, component); } + + /// + /// Removes a batch from the batch queue by index. + /// If the index given does not exist or is outside of the bounds of the lathe's batch queue, nothing happens. + /// + /// The lathe whose queue is being altered. + /// + /// + public void OnLatheDeleteRequestMessage(EntityUid uid, LatheComponent component, ref LatheDeleteRequestMessage args) + { + if (args.Index < 0 || args.Index >= component.Queue.Count) + return; + + var node = component.Queue.First; + for (int i = 0; i < args.Index; i++) + node = node?.Next; + + if (node == null) // Shouldn't happen with checks above. + return; + + var batch = node.Value; + _adminLogger.Add(LogType.Action, + LogImpact.Low, + $"{ToPrettyString(args.Actor):player} deleted a lathe job for ({batch.ItemsPrinted}/{batch.ItemsRequested}) {GetRecipeName(batch.Recipe)} at {ToPrettyString(uid):lathe}"); + + component.Queue.Remove(node); + UpdateUserInterfaceState(uid, component); + } + + public void OnLatheMoveRequestMessage(EntityUid uid, LatheComponent component, ref LatheMoveRequestMessage args) + { + if (args.Change == 0 || args.Index < 0 || args.Index >= component.Queue.Count) + return; + + // New index must be within the bounds of the batch. + var newIndex = args.Index + args.Change; + if (newIndex < 0 || newIndex >= component.Queue.Count) + return; + + var node = component.Queue.First; + for (int i = 0; i < args.Index; i++) + node = node?.Next; + + if (node == null) // Something went wrong. + return; + + if (args.Change > 0) + { + var newRelativeNode = node.Next; + for (int i = 1; i < args.Change; i++) // 1-indexed: starting from Next + newRelativeNode = newRelativeNode?.Next; + + if (newRelativeNode == null) // Something went wrong. + return; + + component.Queue.Remove(node); + component.Queue.AddAfter(newRelativeNode, node); + } + else + { + var newRelativeNode = node.Previous; + for (int i = 1; i < -args.Change; i++) // 1-indexed: starting from Previous + newRelativeNode = newRelativeNode?.Previous; + + if (newRelativeNode == null) // Something went wrong. + return; + + component.Queue.Remove(node); + component.Queue.AddBefore(newRelativeNode, node); + } + + UpdateUserInterfaceState(uid, component); + } + + public void OnLatheAbortFabricationMessage(EntityUid uid, LatheComponent component, ref LatheAbortFabricationMessage args) + { + if (component.CurrentRecipe == null) + return; + + _adminLogger.Add(LogType.Action, + LogImpact.Low, + $"{ToPrettyString(args.Actor):player} aborted printing {GetRecipeName(component.CurrentRecipe.Value)} at {ToPrettyString(uid):lathe}"); + + component.CurrentRecipe = null; + FinishProducing(uid, component); + } #endregion } } diff --git a/Content.Shared/Lathe/LatheComponent.cs b/Content.Shared/Lathe/LatheComponent.cs index 8b701ff64e..7bd7764514 100644 --- a/Content.Shared/Lathe/LatheComponent.cs +++ b/Content.Shared/Lathe/LatheComponent.cs @@ -26,10 +26,14 @@ namespace Content.Shared.Lathe // Otherwise the material arbitrage test and/or LatheSystem.GetAllBaseRecipes needs to be updated /// - /// The lathe's construction queue + /// The lathe's construction queue. /// + /// + /// This is a LinkedList to allow for constant time insertion/deletion (vs a List), and more efficient + /// moves (vs a Queue). + /// [DataField] - public Queue> Queue = new(); + public LinkedList Queue = new(); /// /// The sound that plays when the lathe is producing an item, if any @@ -97,6 +101,21 @@ namespace Content.Shared.Lathe } } + [Serializable] + public sealed partial class LatheRecipeBatch + { + public ProtoId Recipe; + public int ItemsPrinted; + public int ItemsRequested; + + public LatheRecipeBatch(ProtoId recipe, int itemsPrinted, int itemsRequested) + { + Recipe = recipe; + ItemsPrinted = itemsPrinted; + ItemsRequested = itemsRequested; + } + } + /// /// Event raised on a lathe when it starts producing a recipe. /// diff --git a/Content.Shared/Lathe/LatheMessages.cs b/Content.Shared/Lathe/LatheMessages.cs index 1c1c6440f1..fe72eed367 100644 --- a/Content.Shared/Lathe/LatheMessages.cs +++ b/Content.Shared/Lathe/LatheMessages.cs @@ -10,11 +10,11 @@ public sealed class LatheUpdateState : BoundUserInterfaceState { public List> Recipes; - public ProtoId[] Queue; + public LatheRecipeBatch[] Queue; public ProtoId? CurrentlyProducing; - public LatheUpdateState(List> recipes, ProtoId[] queue, ProtoId? currentlyProducing = null) + public LatheUpdateState(List> recipes, LatheRecipeBatch[] queue, ProtoId? currentlyProducing = null) { Recipes = recipes; Queue = queue; @@ -46,6 +46,33 @@ public sealed class LatheQueueRecipeMessage : BoundUserInterfaceMessage } } +/// +/// Sent to the server to remove a batch from the queue. +/// +[Serializable, NetSerializable] +public sealed class LatheDeleteRequestMessage(int index) : BoundUserInterfaceMessage +{ + public int Index = index; +} + +/// +/// Sent to the server to move the position of a batch in the queue. +/// +[Serializable, NetSerializable] +public sealed class LatheMoveRequestMessage(int index, int change) : BoundUserInterfaceMessage +{ + public int Index = index; + public int Change = change; +} + +/// +/// Sent to the server to stop producing the current item. +/// +[Serializable, NetSerializable] +public sealed class LatheAbortFabricationMessage() : BoundUserInterfaceMessage +{ +} + [NetSerializable, Serializable] public enum LatheUiKey { diff --git a/Content.Shared/Lathe/PrototypeIdLinkedListSerializer.cs b/Content.Shared/Lathe/PrototypeIdLinkedListSerializer.cs new file mode 100644 index 0000000000..1c616ff105 --- /dev/null +++ b/Content.Shared/Lathe/PrototypeIdLinkedListSerializer.cs @@ -0,0 +1,81 @@ +using Robust.Shared.Serialization; +using Robust.Shared.Serialization.Manager; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Sequence; +using Robust.Shared.Serialization.Markdown.Validation; +using Robust.Shared.Serialization.TypeSerializers.Interfaces; + +namespace Content.Shared.Lathe; + +/// +/// Handles reading, writing, and validation for linked lists of prototypes. +/// +/// The type of prototype this linked list represents +/// +/// This is in the Content.Shared.Lathe namespace as there are no other LinkedList ProtoId instances. +/// +[TypeSerializer] +public sealed class LinkedListSerializer : ITypeSerializer, SequenceDataNode>, ITypeCopier> where T : class +{ + public ValidationNode Validate(ISerializationManager serializationManager, SequenceDataNode node, + IDependencyCollection dependencies, ISerializationContext? context = null) + { + var list = new List(); + + foreach (var elem in node.Sequence) + { + list.Add(serializationManager.ValidateNode(elem, context)); + } + + return new ValidatedSequenceNode(list); + } + + public DataNode Write(ISerializationManager serializationManager, LinkedList value, + IDependencyCollection dependencies, + bool alwaysWrite = false, + ISerializationContext? context = null) + { + var sequence = new SequenceDataNode(); + + foreach (var elem in value) + { + sequence.Add(serializationManager.WriteValue(elem, alwaysWrite, context)); + } + + return sequence; + } + + LinkedList ITypeReader, SequenceDataNode>.Read(ISerializationManager serializationManager, + SequenceDataNode node, + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context, ISerializationManager.InstantiationDelegate>? instanceProvider) + { + var list = instanceProvider != null ? instanceProvider() : new LinkedList(); + + foreach (var dataNode in node.Sequence) + { + list.AddLast(serializationManager.Read(dataNode, hookCtx, context)); + } + + return list; + } + + public void CopyTo( + ISerializationManager serializationManager, + LinkedList source, + ref LinkedList target, + IDependencyCollection dependencies, + SerializationHookContext hookCtx, + ISerializationContext? context = null) + { + target.Clear(); + using var enumerator = source.GetEnumerator(); + + while (enumerator.MoveNext()) + { + var current = enumerator.Current; + target.AddLast(current); + } + } +} diff --git a/Content.Shared/Lathe/SharedLatheSystem.cs b/Content.Shared/Lathe/SharedLatheSystem.cs index 524d83fd84..5942f4bf6c 100644 --- a/Content.Shared/Lathe/SharedLatheSystem.cs +++ b/Content.Shared/Lathe/SharedLatheSystem.cs @@ -22,6 +22,7 @@ public abstract class SharedLatheSystem : EntitySystem [Dependency] private readonly EmagSystem _emag = default!; public readonly Dictionary> InverseRecipes = new(); + public const int MaxItemsPerRequest = 10_000; public override void Initialize() { @@ -86,6 +87,8 @@ public abstract class SharedLatheSystem : EntitySystem return false; if (!HasRecipe(uid, recipe, component)) return false; + if (amount <= 0) + return false; foreach (var (material, needed) in recipe.Materials) { diff --git a/Resources/Locale/en-US/lathe/ui/lathe-menu.ftl b/Resources/Locale/en-US/lathe/ui/lathe-menu.ftl index 076a70447c..c04c095162 100644 --- a/Resources/Locale/en-US/lathe/ui/lathe-menu.ftl +++ b/Resources/Locale/en-US/lathe/ui/lathe-menu.ftl @@ -29,3 +29,9 @@ lathe-menu-silo-linked-message = Silo Linked lathe-menu-fabricating-message = Fabricating... lathe-menu-materials-title = Materials lathe-menu-queue-title = Build Queue +lathe-menu-delete-fabricating-tooltip = Cancel printing the current item. +lathe-menu-delete-item-tooltip = Cancel printing this batch. +lathe-menu-move-up-tooltip = Move this batch ahead in the queue. +lathe-menu-move-down-tooltip = Move this batch back in the queue. +lathe-menu-item-single = {$index}. {$name} +lathe-menu-item-batch = {$index}. {$name} ({$printed}/{$total})