Skip to content

Commit 040e690

Browse files
committed
fix(block): rank unlocked recipes before horizon choices
1 parent d012f90 commit 040e690

7 files changed

Lines changed: 121 additions & 24 deletions

File tree

app/e2e/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ Mutating (`mut/`, against the isolated server):
106106
- **`module-presets.e2e.ts`** — module templates (#99): save a row's loadout as
107107
a preset from the modules dialog, star it as the default template, and a
108108
compatible new recipe row starts with that loadout baked in.
109+
- **`recipe-order.e2e.ts`** — recipe selection keeps the best currently unlocked
110+
recipe above cheaper choices that are only available in the future horizon.
109111

110112
## CI
111113

app/e2e/mut/recipe-order.e2e.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect, test } from "@playwright/test";
2+
import { addGoal, createBlock, setPlanningHorizon } from "./helpers";
3+
4+
test("recipe picker ranks unlocked recipes before cheaper future tiers", async ({ page }) => {
5+
await createBlock(page);
6+
await addGoal(page, "seaweed", "Seaweed");
7+
await setPlanningHorizon(page, "Future");
8+
9+
await page.locator('button[aria-label^="add a recipe that makes "]').click();
10+
const picker = page.getByRole("dialog", { name: "Recipes that make Seaweed" });
11+
const candidates = picker.locator("[data-recipe-candidate]");
12+
13+
await expect(picker.getByText("Unlocked now", { exact: true })).toBeVisible();
14+
await expect(candidates.first()).toHaveAttribute("data-recipe-candidate", "seaweed-1");
15+
await expect(
16+
picker.locator('[data-recipe-candidate="seaweed-1"]'),
17+
).toContainText("unlocked now");
18+
await expect(
19+
picker.locator('[data-recipe-candidate="seaweed-3"]'),
20+
).toContainText("available in horizon");
21+
});

app/src/components/block/recipe-picker-dialog.tsx

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { fmtCost } from "./format.ts";
99

1010
type Candidate = Awaited<ReturnType<typeof recipeCandidatesFn>>[number];
1111

12-
/** Recipe picker — the candidates that make/consume a good, availability-sorted,
13-
* with io at a glance and TURD/tech lock state. Floats over everything. */
12+
/** Recipe picker — current unlocks first, then horizon choices and locked rows,
13+
* with cost ordering inside each group. Floats over everything. */
1414
export function RecipePickerDialog({
1515
mode,
1616
goodDisplay,
@@ -46,7 +46,15 @@ export function RecipePickerDialog({
4646
const isAdded = added.includes(r.name);
4747
const locked = !r.selectable && !r.superseded;
4848
const previous = candidates[index - 1];
49-
const beginsGroup = !previous || previous.selectable !== r.selectable;
49+
const group = r.unlockedNow ? "now" : r.selectable ? "horizon" : "locked";
50+
const previousGroup = previous
51+
? previous.unlockedNow
52+
? "now"
53+
: previous.selectable
54+
? "horizon"
55+
: "locked"
56+
: null;
57+
const beginsGroup = group !== previousGroup;
5058
const machines = r.machineAvailability.options;
5159
const availableMachine =
5260
machines.find((machine) => machine.availableNow) ?? machines[0];
@@ -55,13 +63,15 @@ export function RecipePickerDialog({
5563
<div key={r.name}>
5664
{beginsGroup && (
5765
<div
58-
className={`px-3 pt-3 pb-1 text-sm font-semibold uppercase ${r.selectable ? "text-success" : "text-destructive"}`}
66+
className={`px-3 pt-3 pb-1 text-sm font-semibold uppercase ${group === "locked" ? "text-destructive" : "text-success"}`}
5967
>
60-
{r.selectable
61-
? r.horizonMode === "now"
62-
? "Unlocked now"
63-
: "Available in planning horizon"
64-
: "Locked or unavailable"}
68+
{group === "now"
69+
? "Unlocked now"
70+
: group === "horizon"
71+
? r.horizonMode === "now"
72+
? "Available with current science"
73+
: "Available in planning horizon"
74+
: "Locked or unavailable"}
6575
</div>
6676
)}
6777
<button
@@ -86,7 +96,7 @@ export function RecipePickerDialog({
8696
<span className="text-base">{r.display ?? r.name}</span>
8797
<span className="ml-auto flex shrink-0 items-center gap-2">
8898
{r.cost != null && (
89-
<Tooltip content="estimated cost per craft (cost analysis) — sorted cheapest first">
99+
<Tooltip content="estimated cost per craft (cost analysis) — sorted cheapest first within each availability group">
90100
<span className="text-sm text-muted-foreground">
91101
¥{fmtCost(r.cost)}
92102
</span>
@@ -170,7 +180,11 @@ export function RecipePickerDialog({
170180
{r.selectable && (
171181
<span className="flex items-center gap-1 text-sm text-success">
172182
<Check className="size-3.5 shrink-0" />
173-
{r.horizonMode === "now" ? "unlocked now" : "available in horizon"}
183+
{r.unlockedNow
184+
? "unlocked now"
185+
: r.horizonMode === "now"
186+
? "available with current science"
187+
: "available in horizon"}
174188
{availableMachine &&
175189
` · ${availableMachine.display ?? availableMachine.name}`}
176190
</span>

app/src/db/queries.server.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -523,10 +523,25 @@ export function unlockedItems(names: string[]): Set<string> {
523523
type MachineOption = ReturnType<typeof machinesForRecipe>[number] & {
524524
startEnabled: boolean;
525525
unlockedBy: { tech: string; display: string | null }[];
526+
unlockedNow: boolean;
526527
availableNow: boolean;
527528
favorite: boolean;
528529
};
529530

531+
/** Whether the recipe exists in the synced save right now, independent of the
532+
* broader planning horizon. TURD recipes count only when their branch is the
533+
* selected one; stale researched-tech data must not resurrect another branch. */
534+
function isRecipeUnlockedNow(
535+
enabled: boolean,
536+
unlocks: RecipeLockState,
537+
researched: ReadonlySet<string>,
538+
): boolean {
539+
return (
540+
enabled ||
541+
unlocks.some((unlock) => (unlock.isTurdSub ? unlock.turdSelected : researched.has(unlock.tech)))
542+
);
543+
}
544+
530545
/** Request-scoped machine enrichment for several recipe candidates. */
531546
export function machineOptionsForRecipes(recipeNames: string[]): Map<string, MachineOption[]> {
532547
const uniq = [...new Set(recipeNames)].filter((name) => name);
@@ -549,6 +564,7 @@ export function machineOptionsForRecipes(recipeNames: string[]): Map<string, Mac
549564
craftRows.map((recipe) => recipe.name),
550565
new Set(selections.values()),
551566
);
567+
const researched = syncedResearchedTechs();
552568
const availability = computeAvailByRecipe(
553569
craftRows,
554570
locksByRecipe,
@@ -585,6 +601,9 @@ export function machineOptionsForRecipes(recipeNames: string[]): Map<string, Mac
585601
...machine,
586602
startEnabled: crafts.some((recipe) => recipe.enabled),
587603
unlockedBy,
604+
unlockedNow: crafts.some((recipe) =>
605+
isRecipeUnlockedNow(recipe.enabled, locksByRecipe.get(recipe.name) ?? [], researched),
606+
),
588607
availableNow: crafts.some((recipe) => availability.get(recipe.name)?.availableNow),
589608
favorite: machine.name === favorite,
590609
};
@@ -4527,10 +4546,11 @@ export function allBlockRecipes(): string[] {
45274546
];
45284547
}
45294548

4530-
/** Recipe-picker candidates (producing/consuming X) with lock + TURD state,
4531-
* sorted into usable and locked groups, cheapest first within each group. A
4532-
* candidate is usable only when both its recipe and at least one compatible
4533-
* building are available under the selected planning horizon. */
4549+
/** Recipe-picker candidates (producing/consuming X) with lock + TURD state.
4550+
* Recipes and buildings unlocked in the synced save rank first, followed by
4551+
* other horizon-usable choices and then locked choices; cost breaks ties inside
4552+
* each group. A candidate is usable only when both its recipe and at least one
4553+
* compatible building are available under the selected planning horizon. */
45344554
export function recipeCandidatesBatch(names: string[], mode: "produce" | "consume") {
45354555
const uniq = [...new Set(names)].filter((name) => name);
45364556
if (!uniq.length) return new Map<string, never[]>();
@@ -4550,6 +4570,7 @@ export function recipeCandidatesBatch(names: string[], mode: "produce" | "consum
45504570
const recipesByNameMap = recipesByName(recipeNames);
45514571
const selections = getTurdSelections();
45524572
const locksByRecipe = recipeLockStatesByRecipe(recipeNames, new Set(selections.values()));
4573+
const researched = syncedResearchedTechs();
45534574
const horizon = getResearchHorizon();
45544575
const availability = computeAvailByRecipe(allBase, locksByRecipe, horizon, selections);
45554576
const machineOptions = machineOptionsForRecipes(recipeNames);
@@ -4586,9 +4607,13 @@ export function recipeCandidatesBatch(names: string[], mode: "produce" | "consum
45864607
: avail.availableNow;
45874608
const superseded = supersededMap.get(r.name) ?? null;
45884609
const selectable = recipeAvailable && machineAvailable && !superseded;
4589-
// Cost orders the usable and locked groups independently. Superseded
4590-
// recipes remain last because the selected TURD removed them in-game.
4591-
const rank = selectable ? 0 : superseded ? 2 : 1;
4610+
const recipeUnlockedNow = isRecipeUnlockedNow(r.enabled, unlocks, researched);
4611+
const machineUnlockedNow =
4612+
machines.length === 0 || machines.some((machine) => machine.unlockedNow);
4613+
const unlockedNow = selectable && recipeUnlockedNow && machineUnlockedNow;
4614+
// Cost orders each availability group independently. Superseded recipes
4615+
// remain last because the selected TURD removed them in-game.
4616+
const rank = unlockedNow ? 0 : selectable ? 1 : superseded ? 3 : 2;
45924617
// io summary so lookalike recipes (Py loves reusing names) tell apart at a glance
45934618
const full = recipesByNameMap.get(r.name);
45944619
return {
@@ -4598,12 +4623,14 @@ export function recipeCandidatesBatch(names: string[], mode: "produce" | "consum
45984623
available,
45994624
avail,
46004625
selectable,
4626+
unlockedNow,
46014627
horizonMode: horizon.mode,
46024628
machineAvailability: {
46034629
available: machineAvailable,
46044630
options: machines.map((machine) => ({
46054631
name: machine.name,
46064632
display: machine.display,
4633+
unlockedNow: machine.unlockedNow,
46074634
availableNow: machine.availableNow,
46084635
startEnabled: machine.startEnabled,
46094636
unlockedBy: machine.unlockedBy,

app/src/db/queries.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,23 @@ describe("recipeCandidates availability: prerequisite-gated techs (empty own cos
609609
const exotic = recipeCandidates("circuit", "produce").find((c) => c.name === "circuit-exotic")!;
610610
expect(exotic.avail.research).toBe("available");
611611
});
612+
613+
it("sorts a currently unlocked recipe before a cheaper horizon-only recipe", () => {
614+
seed();
615+
db.run(sql`
616+
INSERT INTO cost_analysis (scope, name, kind, cost) VALUES
617+
('recipe','circuit-basic','recipe',10),
618+
('recipe','circuit-exotic','recipe',1)
619+
`);
620+
setResearchHorizon({ mode: "future", researched: ["t-basic"] });
621+
622+
const candidates = recipeCandidates("circuit", "produce");
623+
expect(candidates.map((candidate) => candidate.name)).toEqual([
624+
"circuit-basic",
625+
"circuit-exotic",
626+
]);
627+
expect(candidates.map((candidate) => candidate.unlockedNow)).toEqual([true, false]);
628+
});
612629
});
613630

614631
describe("recipeCandidates combined recipe and building availability", () => {
@@ -697,6 +714,19 @@ describe("recipeCandidates combined recipe and building availability", () => {
697714
]);
698715
expect(candidates.every((candidate) => candidate.selectable)).toBe(true);
699716
});
717+
718+
it("sorts a recipe with a current building before a cheaper horizon-only building", () => {
719+
seedGenerators();
720+
setResearchHorizon({ mode: "future", packs: [], researched: [] });
721+
722+
const candidates = recipeCandidates("power", "produce");
723+
expect(candidates.map((candidate) => candidate.name)).toEqual([
724+
"generate-steam",
725+
"generate-wind",
726+
]);
727+
expect(candidates.map((candidate) => candidate.unlockedNow)).toEqual([true, false]);
728+
expect(candidates.every((candidate) => candidate.selectable)).toBe(true);
729+
});
700730
});
701731

702732
describe("batched recipe reads", () => {

app/src/server/agent-tools.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ function optionsFor(
262262
fastest && resolved && fastest.craftingSpeed > resolved.craftingSpeed
263263
? describe(fastest)
264264
: undefined,
265+
unlockedNow: r.unlockedNow, // recipe + at least one building exist in the synced save
265266
// availability vs the user's planning horizon (now vs future)
266267
availableNow: r.avail.availableNow, // research reached, turd not blocked (pickable counts)
267268
buildableNow: r.avail.buildableNow, // stricter: turd ACTIVE — no unmade pick (NOW planning)
@@ -286,7 +287,7 @@ function optionsFor(
286287

287288
export const recipeOptions = tool({
288289
description:
289-
"List the recipes that PRODUCE (or CONSUME) a good, ranked the way the picker ranks them: recipes whose research and building are available under the planning horizon first, then locked choices, cheapest by cost analysis within each group. Each candidate already includes its inputs (in), outputs (out), lock state, cost, and unlocking tech — so you rarely need recipeInfo afterward. Cost is an LP shadow price — a HINT for tie-breaking, NOT the deciding factor: the right recipe is usually about the correct production TIER and chain, not the cheapest. `machine` names the building a draft would ACTUALLY solve with — the user's stored favorite for this recipe's category, else the same safe low-tier fallback `computeBlock` defaults to (never necessarily the fastest); its availability note (\"needs <tech>\") describes THIS machine, since that's what really gates buildability. `fastestMachine` is included only when a strictly faster tier exists beyond that pick, so you can still see whether upgrading the favorite is worth it. To resolve SEVERAL goods at once, prefer recipeOptionsBatch.",
290+
"List the recipes that PRODUCE (or CONSUME) a good, ranked the way the picker ranks them: choices whose recipe and building are unlocked in the synced save first, then other planning-horizon choices, then locked choices; cheapest by cost analysis within each group. `unlockedNow` identifies that first group. Each candidate already includes its inputs (in), outputs (out), lock state, cost, and unlocking tech — so you rarely need recipeInfo afterward. Cost is an LP shadow price — a HINT for tie-breaking, NOT the deciding factor: the right recipe is usually about the correct production TIER and chain, not the cheapest. `machine` names the building a draft would ACTUALLY solve with — the user's stored favorite for this recipe's category, else the same safe low-tier fallback `computeBlock` defaults to (never necessarily the fastest); its availability note (\"needs <tech>\") describes THIS machine, since that's what really gates buildability. `fastestMachine` is included only when a strictly faster tier exists beyond that pick, so you can still see whether upgrading the favorite is worth it. To resolve SEVERAL goods at once, prefer recipeOptionsBatch.",
290291
inputSchema: z.object({
291292
good: z.string().describe("Internal good name (from searchGoods), e.g. 'molten-iron'"),
292293
direction: z

docs/guide/blocks.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,13 @@ instead opens **Recipes that consume _goal_**. Within a recipe row:
5050
- Hover recipe, technology, and item details when you need the precise inputs, outputs, or
5151
unlock status.
5252

53-
The picker groups choices available under the current planning horizon first and sorts each
54-
group by ascending estimated cost. A recipe is available only when both its own research and
55-
at least one compatible building are available. Locked rows are disabled and name the missing
56-
recipe research or building research; recipes replaced by a TURD upgrade remain visible with
57-
their explanation. PyOps never adds a full chain without your choice.
53+
The picker puts choices already unlocked in the synced save first, then recipes available
54+
later in the current planning horizon, and finally locked choices. It sorts each group by
55+
ascending estimated cost, so the first row is normally the cheapest recipe you can build
56+
right now. A recipe is available only when both its own research and at least one compatible
57+
building are available. Locked rows are disabled and name the missing recipe research or
58+
building research; recipes replaced by a TURD upgrade remain visible with their explanation.
59+
PyOps never adds a full chain without your choice.
5860

5961
## Choose machines, fuels, and modules
6062

0 commit comments

Comments
 (0)