Skip to content

Commit 8a4306e

Browse files
committed
feat(block): add best recipe goal shortcut
Use Ctrl+Click or Command+Click on a goal to add the first currently unlocked recipe from the picker's logistic-cost ranking. Exclude barreling choices and keep normal clicks opening the picker.\n\nCloses #152
1 parent 075d1fb commit 8a4306e

6 files changed

Lines changed: 131 additions & 8 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect, test } from "@playwright/test";
2+
import { addGoal, createBlock, expectUndoTop } from "./helpers";
3+
4+
test("Ctrl+Click adds the picker's best currently unlocked goal recipe", async ({ page }) => {
5+
await createBlock(page);
6+
await addGoal(page, "iron plate", "Iron plate");
7+
await expectUndoTop(page, /Undo: Edit block "Iron plate"/);
8+
9+
const goal = page.getByRole("button", { name: "add a recipe that makes Iron plate" });
10+
await goal.click();
11+
const picker = page.getByRole("dialog", { name: "Recipes that make Iron plate" });
12+
await expect(picker.getByText("Unlocked now", { exact: true })).toBeVisible();
13+
const best = picker.locator("[data-recipe-candidate]").first();
14+
const bestName = await best.getAttribute("data-recipe-candidate");
15+
expect(bestName).toBeTruthy();
16+
expect(bestName).not.toMatch(/barrel/i);
17+
await page.keyboard.press("Escape");
18+
await expect(picker).toBeHidden();
19+
20+
await goal.click({ modifiers: ["Control"] });
21+
await expect(page.locator(`[data-recipe-row="${bestName}"]`)).toBeVisible();
22+
await expect(picker).toBeHidden();
23+
await expectUndoTop(page, /Undo: Add recipe/);
24+
});

app/src/components/block/goal-card.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function GoalCard({
4444
onGoalMenu,
4545
onMakeFor,
4646
onUseFor,
47+
onQuickRecipeFor,
4748
onOpenGoalPicker,
4849
}: {
4950
doc: BlockDocStore;
@@ -55,6 +56,7 @@ export function GoalCard({
5556
onGoalMenu: (e: { clientX: number; clientY: number }, name: string) => void;
5657
onMakeFor: (name: string) => void;
5758
onUseFor: (name: string) => void;
59+
onQuickRecipeFor: (name: string, mode: "produce" | "consume") => void;
5860
onOpenGoalPicker: () => void;
5961
}) {
6062
const goals = useStore(doc.store, (s) => s.goals);
@@ -123,13 +125,16 @@ export function GoalCard({
123125
const extraText = goalMissing ? (
124126
"No longer exists in the current data."
125127
) : goalUnmade ? (
126-
`No recipe in this block ${consumes ? "consumes" : "makes"} it. Click to add one.`
128+
`No recipe in this block ${consumes ? "consumes" : "makes"} it. Click to choose one, or Ctrl+Click to add the best unlocked non-barreling recipe.`
127129
) : (
128130
<div className="space-y-1">
129131
<div>
130132
{isFirst ? "Primary goal · names the block" : "Goal"} · right-click for
131133
options
132134
</div>
135+
<div className="text-muted-foreground">
136+
Ctrl+Click · add best unlocked non-barreling recipe
137+
</div>
133138
{incidentalRate > 0 && (
134139
<div className="flex items-center gap-1 text-warning">
135140
<Timer className="size-3.5" /> {num(incidentalRate)}/s estimated incidental
@@ -192,7 +197,14 @@ export function GoalCard({
192197
</button>
193198
</div>
194199
<button
195-
onClick={() => (consumes ? onUseFor(g) : onMakeFor(g))}
200+
onClick={(event) => {
201+
if (event.ctrlKey || event.metaKey) {
202+
onQuickRecipeFor(g, consumes ? "consume" : "produce");
203+
return;
204+
}
205+
if (consumes) onUseFor(g);
206+
else onMakeFor(g);
207+
}}
196208
aria-label={`add a recipe that ${consumes ? "consumes" : "makes"} ${res?.display?.[g] ?? g}`}
197209
>
198210
<Icon kind={kind} name={g} size="lg" extraText={extraText} />
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { bestUnlockedNonBarrelingRecipe, isBarrelingRecipe } from "./recipe-shortcuts.ts";
3+
4+
describe("best unlocked recipe shortcut", () => {
5+
it("takes the first unlocked non-barreling candidate from picker order", () => {
6+
const candidates = [
7+
{ name: "fill-water-barrel", category: "py-barreling", unlockedNow: true },
8+
{ name: "pump-water", category: "water", unlockedNow: true },
9+
{ name: "distill-water", category: "chemistry", unlockedNow: true },
10+
];
11+
12+
expect(bestUnlockedNonBarrelingRecipe(candidates)?.name).toBe("pump-water");
13+
});
14+
15+
it("does not fall through to horizon, locked, or superseded choices", () => {
16+
expect(
17+
bestUnlockedNonBarrelingRecipe([
18+
{ name: "future", unlockedNow: false },
19+
{ name: "old", unlockedNow: true, superseded: { by: "new" } },
20+
]),
21+
).toBeUndefined();
22+
});
23+
24+
it("recognizes standard categories and custom-category barrel names", () => {
25+
expect(isBarrelingRecipe({ name: "fill-water", category: "barrelling" })).toBe(true);
26+
expect(isBarrelingRecipe({ name: "mod-fill-water-barrel", category: "chemistry" })).toBe(true);
27+
expect(isBarrelingRecipe({ name: "pump-water", category: "water" })).toBe(false);
28+
});
29+
});

app/src/lib/recipe-shortcuts.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const BARREL_CATEGORIES = new Set(["py-barreling", "py-unbarreling", "barreling", "barrelling"]);
2+
3+
export type RankedRecipeCandidate = {
4+
name: string;
5+
category?: string | null;
6+
unlockedNow: boolean;
7+
superseded?: unknown;
8+
};
9+
10+
/** Barrel fill/empty recipes are transport plumbing, not the production choice
11+
* a goal shortcut should make. Category is authoritative; the name fallback
12+
* covers modded variants that use a custom category. */
13+
export function isBarrelingRecipe(recipe: Pick<RankedRecipeCandidate, "name" | "category">) {
14+
return (
15+
BARREL_CATEGORIES.has(recipe.category ?? "") || recipe.name.toLowerCase().includes("barrel")
16+
);
17+
}
18+
19+
/** Candidates already carry the recipe picker's authoritative ordering:
20+
* currently unlocked recipe+machine first, then logistic cost and display name.
21+
* Select the first eligible entry rather than recreating that ranking here. */
22+
export function bestUnlockedNonBarrelingRecipe<T extends RankedRecipeCandidate>(
23+
candidates: readonly T[],
24+
): T | undefined {
25+
return candidates.find(
26+
(candidate) => candidate.unlockedNow && !candidate.superseded && !isBarrelingRecipe(candidate),
27+
);
28+
}

app/src/routes/block.$id.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { blockActionName } from "../lib/undo-names";
3030
import { launchesForRate, resolveLogistics } from "../lib/logistics";
3131
import { recordRecent } from "../lib/recents";
3232
import { createCoalescedRunner } from "../lib/coalesced-runner";
33+
import { bestUnlockedNonBarrelingRecipe } from "../lib/recipe-shortcuts";
3334
import { IconProvider, useSpoilables } from "../lib/icons";
3435
import { ModulesModal } from "../lib/modules-modal";
3536
import { Callout } from "#/components/ui/callout.tsx";
@@ -156,9 +157,11 @@ function Block({ blockId }: { blockId: number }) {
156157
toast({ message: `Extracted "${out.name}" into a new block.` });
157158
void navigate({ to: "/block/$id", params: { id: String(out.id) } });
158159
};
159-
const [pickFor, setPickFor] = useState<{ name: string; mode: "produce" | "consume" } | null>(
160-
null,
161-
);
160+
const [pickFor, setPickFor] = useState<{
161+
name: string;
162+
mode: "produce" | "consume";
163+
quick?: boolean;
164+
} | null>(null);
162165
const [pickMachineFor, setPickMachineFor] = useState<string | null>(null); // recipe whose machine we're choosing
163166
const [pickFuelFor, setPickFuelFor] = useState<string | null>(null); // recipe whose fuel we're choosing
164167
const [pickModulesFor, setPickModulesFor] = useState<string | null>(null); // recipe whose modules we're editing
@@ -579,12 +582,33 @@ function Block({ blockId }: { blockId: number }) {
579582
});
580583
};
581584

585+
const quickRecipe = pickFor?.quick
586+
? bestUnlockedNonBarrelingRecipe(picker.data ?? [])
587+
: undefined;
588+
useEffect(() => {
589+
if (!pickFor?.quick || (!picker.isSuccess && !picker.isError)) return;
590+
if (picker.isError) {
591+
toast({ message: "Could not load recipe choices.", tone: "destructive" });
592+
setPickFor(null);
593+
} else if (quickRecipe) add(quickRecipe.name);
594+
else {
595+
toast({
596+
message: `No unlocked non-barreling recipe is available for ${res?.display?.[pickFor.name] ?? pickFor.name}.`,
597+
});
598+
setPickFor(null);
599+
}
600+
// `add` intentionally consumes the current picker state and closes it.
601+
// eslint-disable-next-line react-hooks/exhaustive-deps
602+
}, [pickFor?.quick, picker.isSuccess, picker.isError, quickRecipe?.name]);
603+
582604
// When a flow has exactly one craftable recipe, skip the picker dialog and add
583605
// it directly. A superseded recipe (its base no longer exists in-game) or one
584606
// that's already in the block still opens the dialog, so the explanation/state
585607
// stays visible rather than the click silently doing nothing.
586608
const loneRecipe =
587-
pickFor && picker.data?.length === 1 && !picker.data[0].superseded ? picker.data[0] : null;
609+
pickFor && !pickFor.quick && picker.data?.length === 1 && !picker.data[0].superseded
610+
? picker.data[0]
611+
: null;
588612
const autoAddRecipe = loneRecipe && !recipes.includes(loneRecipe.name) ? loneRecipe.name : null;
589613
useEffect(() => {
590614
if (autoAddRecipe) add(autoAddRecipe);
@@ -718,6 +742,8 @@ function Block({ blockId }: { blockId: number }) {
718742
: "linked";
719743
const makeFor = (name: string) => setPickFor({ name, mode: "produce" });
720744
const useFor = (name: string) => setPickFor({ name, mode: "consume" });
745+
const quickRecipeFor = (name: string, mode: "produce" | "consume") =>
746+
setPickFor({ name, mode, quick: true });
721747
const openCtxMenu = (
722748
e: { clientX: number; clientY: number },
723749
d: { name: string; kind: string; link: ItemLink },
@@ -808,6 +834,7 @@ function Block({ blockId }: { blockId: number }) {
808834
onGoalMenu={(e, name) => setGoalMenu({ x: e.clientX, y: e.clientY, name })}
809835
onMakeFor={makeFor}
810836
onUseFor={useFor}
837+
onQuickRecipeFor={quickRecipeFor}
811838
onOpenGoalPicker={() => setGoalPicker({})}
812839
/>
813840
<BalanceCard
@@ -953,7 +980,7 @@ function Block({ blockId }: { blockId: number }) {
953980
)}
954981

955982
{/* Recipe picker — floats over everything, dismissable */}
956-
{pickFor && !picker.isLoading && !autoAddRecipe && (
983+
{pickFor && !pickFor.quick && !picker.isLoading && !autoAddRecipe && (
957984
<RecipePickerDialog
958985
mode={pickFor.mode}
959986
goodDisplay={res?.display?.[pickFor.name] ?? pickFor.name}

docs/guide/blocks.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ remain unsourced. Right-click an import to jump to the blocks that do produce it
5656
## Add recipes
5757

5858
Select a positive goal icon to open **Recipes that make _goal_**. A negative consume goal
59-
instead opens **Recipes that consume _goal_**. Within a recipe row:
59+
instead opens **Recipes that consume _goal_**. Ctrl+Click (or Command+Click) a goal icon to
60+
immediately add its highest-ranked currently unlocked recipe. This shortcut uses the same
61+
logistic-cost ordering as the picker and ignores barrel fill/empty recipes. Within a recipe
62+
row:
6063

6164
- Select an ingredient chip to find recipes that make that ingredient.
6265
- Select a product chip to find recipes that consume that product.

0 commit comments

Comments
 (0)