Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/app/Components/Planner/ContextMenu/NodeContextMenu.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {faArrowsSplitUpAndLeft, faBan, faCheck, faCompress, faCrosshairs, faDiagramProject, faExpand, faFolderOpen, faLock, faLockOpen, faRotateLeft, faTrashCan, faXmark} from '@fortawesome/free-solid-svg-icons';
import {faArrowsSplitUpAndLeft, faBan, faCheck, faCompress, faCrosshairs, faDiagramProject, faExpand, faFolderOpen, faLock, faLockOpen, faRightLeft, faRotateLeft, faTrashCan, faXmark} from '@fortawesome/free-solid-svg-icons';
import {ContextMenuItem} from '@src/Components/Planner/ContextMenu/ContextMenuItem';
import {NodeResizeOptions} from '@src/Components/Planner/ContextMenu/NodeResizeOptions';
import {NodeSplitOptions} from '@src/Components/Planner/ContextMenu/NodeSplitOptions';
import {PlannerContextMenu} from '@src/Components/Planner/ContextMenu/PlannerContextMenu';
import {PlannerContextMenuService} from '@src/Components/Planner/ContextMenu/PlannerContextMenuService';
import {RecipeSwapMenu} from '@src/Components/Planner/ContextMenu/RecipeSwapMenu';
import {PanelLayoutService} from '@src/Components/Planner/Panel/PanelLayoutService';
import {PlannerActionsService} from '@src/Components/Planner/PlannerActionsService';
import {PlannerGraphService} from '@src/Components/Planner/PlannerGraphService';
Expand All @@ -24,9 +26,11 @@ export class NodeContextMenu extends PlannerContextMenu
private readonly node: Node,
private readonly resize: NodeResizeOptions,
private readonly splits: NodeSplitOptions,
private readonly recipeSwap: RecipeSwapMenu | null,
private readonly actions: PlannerActionsService,
private readonly panelLayout: PanelLayoutService,
private readonly plannerGraph: PlannerGraphService,
private readonly contextMenu: PlannerContextMenuService,
)
{
super();
Expand Down Expand Up @@ -117,7 +121,21 @@ export class NodeContextMenu extends PlannerContextMenu
const node = this.node;

if (node instanceof RecipeNode) {
const recipeSwap = this.recipeSwap;
return [
{
label: 'Change recipe…',
icon: faRightLeft,
disabled: recipeSwap === null,
hint: recipeSwap === null ? `No other recipe makes ${node.recipe.products[0]?.item.name ?? 'this'}.` : undefined,
action: () => {
if (recipeSwap) {
// Opens where this menu was, which closed just before the action ran.
const {x, y} = this.contextMenu.position();
this.contextMenu.open(recipeSwap, x, y);
}
},
},
{
label: 'Disable recipe',
icon: faBan,
Expand Down
39 changes: 39 additions & 0 deletions src/app/Components/Planner/ContextMenu/RecipeSwapMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {ContextMenuItem} from '@src/Components/Planner/ContextMenu/ContextMenuItem';
import {PlannerContextMenu} from '@src/Components/Planner/ContextMenu/PlannerContextMenu';
import {NodeIO} from '@src/Model/Planner/Solver/Response/NodeIO';
import {RecipeNode} from '@src/Model/Planner/Solver/Response/RecipeNode';
import {RateFormatter} from '@src/Model/RateFormatter';

export class RecipeSwapMenu extends PlannerContextMenu
{

/** Each option is the finished replacement node, so the hint shows exactly what the swap would need and make. */
public constructor(
private readonly options: RecipeNode[],
private readonly rateFormatter: RateFormatter,
private readonly onPick: (option: RecipeNode) => void,
)
{
super();
}

public override getTitle(): string
{
return 'Change recipe';
}

public getItems(): ContextMenuItem[]
{
return this.options.map(option => ({
label: option.recipe.name,
hint: `${this.rates(option.inputs)} → ${this.rates(option.outputs)}`,
action: () => this.onPick(option),
}));
}

private rates(ios: NodeIO[]): string
{
return ios.map(io => `${this.rateFormatter.rate(io.maxAmount, io.item)} ${io.item.name}`).join(', ');
}

}
38 changes: 35 additions & 3 deletions src/app/Components/Planner/PlannerComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {NodeSplitOptions} from '@src/Components/Planner/ContextMenu/NodeSplitOpt
import {PlannerContextMenu} from '@src/Components/Planner/ContextMenu/PlannerContextMenu';
import {PlannerContextMenuComponent} from '@src/Components/Planner/ContextMenu/PlannerContextMenuComponent';
import {PlannerContextMenuService} from '@src/Components/Planner/ContextMenu/PlannerContextMenuService';
import {RecipeSwapMenu} from '@src/Components/Planner/ContextMenu/RecipeSwapMenu';
import {CalculatorTabHotkeys} from '@src/Components/Planner/Panels/Calculator/CalculatorTabHotkeys';
import {CalculatorTabStateService} from '@src/Components/Planner/Panels/Calculator/CalculatorTabStateService';
import {HotkeyAction} from '@src/Model/Hotkeys/HotkeyAction';
Expand Down Expand Up @@ -77,6 +78,7 @@ import {ByproductNode} from '@src/Model/Planner/Solver/Response/ByproductNode';
import {InputNode} from '@src/Model/Planner/Solver/Response/InputNode';
import {Node} from '@src/Model/Planner/Solver/Response/Node';
import {ProductNode} from '@src/Model/Planner/Solver/Response/ProductNode';
import {RecipeNode} from '@src/Model/Planner/Solver/Response/RecipeNode';
import {SolverResponse} from '@src/Model/Planner/Solver/Response/SolverResponse';
import {SubplanNode} from '@src/Model/Planner/Solver/Response/SubplanNode';
import {SubplanBuildCountRequest} from '@src/Components/Planner/SubplanBuildCountRequest';
Expand Down Expand Up @@ -710,8 +712,8 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou
const items: HotkeyItem[] = [...new BlankContextMenu(this.actions, this.plannerGraph.canvasCenter()).getItems()];
const nodes = this.plannerGraph.selectedNodes();
if (nodes.length === 1) {
items.push(...new NodeContextMenu(nodes[0], this.nodeResizeOptions(nodes[0]), this.nodeSplitOptions(nodes[0]),
this.actions, this.panelLayout, this.plannerGraph).getItems());
items.push(...new NodeContextMenu(nodes[0], this.nodeResizeOptions(nodes[0]), this.nodeSplitOptions(nodes[0]), this.recipeSwapMenu(nodes[0]),
this.actions, this.panelLayout, this.plannerGraph, this.contextMenu).getItems());
} else if (nodes.length > 1) {
items.push(...new MultiNodeContextMenu(nodes, this.actions).getItems());
}
Expand Down Expand Up @@ -783,7 +785,7 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou
menu = new BlankContextMenu(this.actions, request.local);
} else if (request.nodes.length === 1) {
menu = new NodeContextMenu(request.nodes[0], this.nodeResizeOptions(request.nodes[0]), this.nodeSplitOptions(request.nodes[0]),
this.actions, this.panelLayout, this.plannerGraph);
this.recipeSwapMenu(request.nodes[0]), this.actions, this.panelLayout, this.plannerGraph, this.contextMenu);
} else {
menu = new MultiNodeContextMenu(request.nodes, this.actions);
}
Expand Down Expand Up @@ -853,6 +855,25 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou
};
}

/** Other recipes for the node's main product, each sized to keep that product's output. */
private recipeSwapMenu(node: Node): RecipeSwapMenu | null
{
const data = this.versionManager.activeVersionData();
if (!(node instanceof RecipeNode) || !data) {
return null;
}
const product = node.recipe.products[0]?.item;
if (!product) {
return null;
}
const options = data.getRecipesProducingItem(product.className)
.filter(recipe => recipe.className !== node.recipe.className && recipe.producedIn.length > 0)
.sort((a, b) => a.name.localeCompare(b.name))
.map(recipe => this.nodeResizer.withRecipe(node, recipe, product.className))
.filter(option => option !== null);
return options.length > 0 ? new RecipeSwapMenu(options, this.rateFormatter, option => this.applyRecipeSwap(option)) : null;
}

private nodeSplitOptions(node: Node): NodeSplitOptions
{
const none: NodeSplitOptions = {inputs: 1, outputs: 1, both: 1};
Expand Down Expand Up @@ -954,6 +975,17 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou
this.planManager.setGraph(plan.id, reconciled, true);
}

/** A swap leaves the graph unbalanced, so automatic mode re-solves at once - unless that would discard earlier hand edits. The swapped node is locked, so the solve keeps it. */
private applyRecipeSwap(node: RecipeNode): void
{
const plan = this.planManager.activePlan();
const resolve = plan !== null && this.modeOf(plan) === 'automatic' && !(plan.metadata?.graphDirty ?? false);
this.applyNodeUpdate(node);
if (resolve) {
this.calculate();
}
}

private applyEdgeAdd(request: GraphEdgeAddRequest): void
{
const prepared = this.prepareEdgeAdd(request);
Expand Down
28 changes: 24 additions & 4 deletions src/app/Model/Planner/NodeResizer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {Injectable} from '@angular/core';
import {Building} from '@src/Model/Data/Entities/Building';
import {Recipe} from '@src/Model/Data/Entities/Recipe';
import {ClockSpeedResolver} from '@src/Model/Planner/ClockSpeedResolver';
import {Formulas} from '@src/Model/Planner/Formulas';
import {GraphEdge} from '@src/Model/Planner/Graph/GraphEdge';
Expand All @@ -15,7 +17,7 @@ import {ProductNode} from '@src/Model/Planner/Solver/Response/ProductNode';
import {RecipeNode} from '@src/Model/Planner/Solver/Response/RecipeNode';
import {SinkNode} from '@src/Model/Planner/Solver/Response/SinkNode';

/** Resizing is a manual edit, so replacements come back locked - except withAmount, which serves elastic bookkeeping. */
/** Resizing and recipe swaps are manual edits, so replacements come back locked - except withAmount, which serves elastic bookkeeping. */
@Injectable({providedIn: 'root'})
export class NodeResizer
{
Expand Down Expand Up @@ -108,6 +110,24 @@ export class NodeResizer
return this.placed(node, new GeneratorNode(node.id, amount, node.generator, node.fuel, clockSpeed), true);
}

/** Keeps the node's output of `itemClassName`, and its machine when the new recipe can run in it. */
public withRecipe(node: RecipeNode, recipe: Recipe, itemClassName: string): RecipeNode | null
{
const machine = recipe.producedIn.find(candidate => candidate.className === node.machine.className) ?? recipe.producedIn[0];
const product = recipe.products.find(entry => entry.item.className === itemClassName);
if (!machine || !product) {
return null;
}
const sloops = this.normalizer.clampSloops(this.uniformSloops(node.groups), machine);
const perTarget = product.amount
* Formulas.referenceCycles(recipe, machine)
* Formulas.sloopOutputMultiplier(machine, sloops);
if (perTarget <= 0) {
return null;
}
return this.rebuiltRecipe(node, this.outputTotal(node, itemClassName) / perTarget, sloops, recipe, machine);
}

public edgeRatios(node: Node, edges: GraphEdge[]): number[]
{
const ratios: number[] = [];
Expand All @@ -132,13 +152,13 @@ export class NodeResizer
});
}

private rebuiltRecipe(node: RecipeNode, target: number, sloops: number): RecipeNode | null
private rebuiltRecipe(node: RecipeNode, target: number, sloops: number, recipe: Recipe = node.recipe, machine: Building = node.machine): RecipeNode | null
{
if (target <= 0) {
return null;
}
const groups = this.normalizer.generateForTarget(target, this.clocks.forRecipe(node.recipe, node.machine), sloops, node.groupingMode);
const replacement = new RecipeNode(node.id, target, groups, node.machine, node.recipe);
const groups = this.normalizer.generateForTarget(target, this.clocks.forRecipe(recipe, machine), sloops, node.groupingMode);
const replacement = new RecipeNode(node.id, target, groups, machine, recipe);
replacement.groupingMode = node.groupingMode;
return this.placed(node, replacement, true);
}
Expand Down