diff --git a/src/app/Components/Planner/ContextMenu/NodeContextMenu.ts b/src/app/Components/Planner/ContextMenu/NodeContextMenu.ts index 9cd6ada..1598eb1 100644 --- a/src/app/Components/Planner/ContextMenu/NodeContextMenu.ts +++ b/src/app/Components/Planner/ContextMenu/NodeContextMenu.ts @@ -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'; @@ -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(); @@ -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, diff --git a/src/app/Components/Planner/ContextMenu/RecipeSwapMenu.ts b/src/app/Components/Planner/ContextMenu/RecipeSwapMenu.ts new file mode 100644 index 0000000..cff5f09 --- /dev/null +++ b/src/app/Components/Planner/ContextMenu/RecipeSwapMenu.ts @@ -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(', '); + } + +} diff --git a/src/app/Components/Planner/PlannerComponent.ts b/src/app/Components/Planner/PlannerComponent.ts index 9f02922..5a8bc5a 100644 --- a/src/app/Components/Planner/PlannerComponent.ts +++ b/src/app/Components/Planner/PlannerComponent.ts @@ -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'; @@ -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'; @@ -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()); } @@ -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); } @@ -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}; @@ -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); diff --git a/src/app/Model/Planner/NodeResizer.ts b/src/app/Model/Planner/NodeResizer.ts index 58cca87..e1e223f 100644 --- a/src/app/Model/Planner/NodeResizer.ts +++ b/src/app/Model/Planner/NodeResizer.ts @@ -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'; @@ -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 { @@ -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[] = []; @@ -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); }