Batchable lathe jobs, editable lathe job order (#38624)

* batchable lathe jobs, editable order

* requested changes

* LatheComponent comment, menu strings
This commit is contained in:
Whatstone
2025-08-24 11:02:47 -04:00
committed by GitHub
parent 30aa61c29c
commit b5529ecf2b
11 changed files with 397 additions and 35 deletions

View File

@@ -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)

View File

@@ -1,6 +1,7 @@
<DefaultWindow
xmlns="https://spacestation14.io"
xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="clr-namespace:Content.Client.Materials.UI"
Title="{Loc 'lathe-menu-title'}"
MinSize="550 450"
@@ -110,6 +111,18 @@
HorizontalAlignment="Left"
Margin="130 0 0 0">
</Label>
<Button
Name="DeleteFabricating"
Margin="0"
Text="✖"
SetSize="38 32"
HorizontalAlignment="Right"
ToolTip="{Loc 'lathe-menu-delete-fabricating-tooltip'}">
<Button.StyleClasses>
<system:String>Caution</system:String>
<system:String>OpenLeft</system:String>
</Button.StyleClasses>
</Button>
</PanelContainer>
</BoxContainer>
<ScrollContainer VerticalExpand="True" HScrollEnabled="False">

View File

@@ -26,6 +26,10 @@ public sealed partial class LatheMenu : DefaultWindow
public event Action<BaseButton.ButtonEventArgs>? OnServerListButtonPressed;
public event Action<string, int>? RecipeQueueAction;
public event Action<int>? QueueDeleteAction;
public event Action<int>? QueueMoveUpAction;
public event Action<int>? QueueMoveDownAction;
public event Action? DeleteFabricatingAction;
public List<ProtoId<LatheRecipePrototype>> 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
/// </summary>
/// <param name="queue"></param>
public void PopulateQueueList(IReadOnlyCollection<ProtoId<LatheRecipePrototype>> queue)
public void PopulateQueueList(IReadOnlyCollection<LatheRecipeBatch> 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++;
}

View File

@@ -0,0 +1,35 @@
<Control xmlns="https://spacestation14.io"
xmlns:system="clr-namespace:System;assembly=System.Runtime">
<BoxContainer Orientation="Horizontal">
<BoxContainer
Name="RecipeDisplayContainer"
Margin="0 0 4 0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MinSize="32 32"
/>
<Label Name="RecipeName" HorizontalExpand="True" />
<Button
Name="MoveUp"
Margin="0"
Text="⏶"
StyleClasses="OpenRight"
ToolTip="{Loc 'lathe-menu-move-up-tooltip'}"/>
<Button
Name="MoveDown"
Margin="0"
Text="⏷"
StyleClasses="OpenBoth"
ToolTip="{Loc 'lathe-menu-move-down-tooltip'}"/>
<Button
Name="Delete"
Margin="0"
Text="✖"
ToolTip="{Loc 'lathe-menu-delete-item-tooltip'}">
<Button.StyleClasses>
<system:String>Caution</system:String>
<system:String>OpenLeft</system:String>
</Button.StyleClasses>
</Button>
</BoxContainer>
</Control>

View File

@@ -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<int>? OnDeletePressed;
public Action<int>? OnMoveUpPressed;
public Action<int>? 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);
};
}
}

View File

@@ -74,6 +74,9 @@ namespace Content.Server.Lathe
SubscribeLocalEvent<LatheComponent, LatheQueueRecipeMessage>(OnLatheQueueRecipeMessage);
SubscribeLocalEvent<LatheComponent, LatheSyncRequestMessage>(OnLatheSyncRequestMessage);
SubscribeLocalEvent<LatheComponent, LatheDeleteRequestMessage>(OnLatheDeleteRequestMessage);
SubscribeLocalEvent<LatheComponent, LatheMoveRequestMessage>(OnLatheMoveRequestMessage);
SubscribeLocalEvent<LatheComponent, LatheAbortFabricationMessage>(OnLatheAbortFabricationMessage);
SubscribeLocalEvent<LatheComponent, BeforeActivatableUIOpenEvent>((u, c, _) => UpdateUserInterfaceState(u, c));
SubscribeLocalEvent<LatheComponent, MaterialAmountChangedEvent>(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<LatheProducingComponent>(uid);
UpdateRunningAppearance(uid, false);
AbortProduction(uid);
}
else if (component.CurrentRecipe != null)
else
{
EnsureComp<LatheProducingComponent>(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<LatheProducingComponent>(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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="uid">The lathe whose queue is being altered.</param>
/// <param name="component"></param>
/// <param name="args"></param>
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
}
}

View File

@@ -26,10 +26,14 @@ namespace Content.Shared.Lathe
// Otherwise the material arbitrage test and/or LatheSystem.GetAllBaseRecipes needs to be updated
/// <summary>
/// The lathe's construction queue
/// The lathe's construction queue.
/// </summary>
/// <remarks>
/// This is a LinkedList to allow for constant time insertion/deletion (vs a List), and more efficient
/// moves (vs a Queue).
/// </remarks>
[DataField]
public Queue<ProtoId<LatheRecipePrototype>> Queue = new();
public LinkedList<LatheRecipeBatch> Queue = new();
/// <summary>
/// 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<LatheRecipePrototype> Recipe;
public int ItemsPrinted;
public int ItemsRequested;
public LatheRecipeBatch(ProtoId<LatheRecipePrototype> recipe, int itemsPrinted, int itemsRequested)
{
Recipe = recipe;
ItemsPrinted = itemsPrinted;
ItemsRequested = itemsRequested;
}
}
/// <summary>
/// Event raised on a lathe when it starts producing a recipe.
/// </summary>

View File

@@ -10,11 +10,11 @@ public sealed class LatheUpdateState : BoundUserInterfaceState
{
public List<ProtoId<LatheRecipePrototype>> Recipes;
public ProtoId<LatheRecipePrototype>[] Queue;
public LatheRecipeBatch[] Queue;
public ProtoId<LatheRecipePrototype>? CurrentlyProducing;
public LatheUpdateState(List<ProtoId<LatheRecipePrototype>> recipes, ProtoId<LatheRecipePrototype>[] queue, ProtoId<LatheRecipePrototype>? currentlyProducing = null)
public LatheUpdateState(List<ProtoId<LatheRecipePrototype>> recipes, LatheRecipeBatch[] queue, ProtoId<LatheRecipePrototype>? currentlyProducing = null)
{
Recipes = recipes;
Queue = queue;
@@ -46,6 +46,33 @@ public sealed class LatheQueueRecipeMessage : BoundUserInterfaceMessage
}
}
/// <summary>
/// Sent to the server to remove a batch from the queue.
/// </summary>
[Serializable, NetSerializable]
public sealed class LatheDeleteRequestMessage(int index) : BoundUserInterfaceMessage
{
public int Index = index;
}
/// <summary>
/// Sent to the server to move the position of a batch in the queue.
/// </summary>
[Serializable, NetSerializable]
public sealed class LatheMoveRequestMessage(int index, int change) : BoundUserInterfaceMessage
{
public int Index = index;
public int Change = change;
}
/// <summary>
/// Sent to the server to stop producing the current item.
/// </summary>
[Serializable, NetSerializable]
public sealed class LatheAbortFabricationMessage() : BoundUserInterfaceMessage
{
}
[NetSerializable, Serializable]
public enum LatheUiKey
{

View File

@@ -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;
/// <summary>
/// Handles reading, writing, and validation for linked lists of prototypes.
/// </summary>
/// <typeparam name="T">The type of prototype this linked list represents</typeparam>
/// <remarks>
/// This is in the Content.Shared.Lathe namespace as there are no other LinkedList ProtoId instances.
/// </remarks>
[TypeSerializer]
public sealed class LinkedListSerializer<T> : ITypeSerializer<LinkedList<T>, SequenceDataNode>, ITypeCopier<LinkedList<T>> where T : class
{
public ValidationNode Validate(ISerializationManager serializationManager, SequenceDataNode node,
IDependencyCollection dependencies, ISerializationContext? context = null)
{
var list = new List<ValidationNode>();
foreach (var elem in node.Sequence)
{
list.Add(serializationManager.ValidateNode<T>(elem, context));
}
return new ValidatedSequenceNode(list);
}
public DataNode Write(ISerializationManager serializationManager, LinkedList<T> 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<T> ITypeReader<LinkedList<T>, SequenceDataNode>.Read(ISerializationManager serializationManager,
SequenceDataNode node,
IDependencyCollection dependencies,
SerializationHookContext hookCtx,
ISerializationContext? context, ISerializationManager.InstantiationDelegate<LinkedList<T>>? instanceProvider)
{
var list = instanceProvider != null ? instanceProvider() : new LinkedList<T>();
foreach (var dataNode in node.Sequence)
{
list.AddLast(serializationManager.Read<T>(dataNode, hookCtx, context));
}
return list;
}
public void CopyTo(
ISerializationManager serializationManager,
LinkedList<T> source,
ref LinkedList<T> 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);
}
}
}

View File

@@ -22,6 +22,7 @@ public abstract class SharedLatheSystem : EntitySystem
[Dependency] private readonly EmagSystem _emag = default!;
public readonly Dictionary<string, List<LatheRecipePrototype>> 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)
{

View File

@@ -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})