Skip to content

Commit e18679d

Browse files
committed
fix(app): gate recipe availability on a tech's full prerequisite closure, not its own cost
techReachedByScience decided 'is this tech reachable in the horizon?' from the tech's OWN direct science cost. A tech gated purely through prerequisites has an empty own cost (e.g. Py's TURD-unlocked `neuron`), and [].every() is vacuously true — so it read as reachable at ANY tier. That surfaced a tier-5 Simple circuit board recipe (scrondrix, via neuron) as 'available now' in an automation-science-only horizon, misleading the assistant, the recipe picker, the deps explorer, and machine/TURD gating. Gate on the full prerequisite-closure packs instead (memoized per project db, like the horizon's own target-closure cache). Prune already-researched techs so a researched prerequisite isn't re-demanded in NOW mode. Verified on the real py-hard-mode data: with target=Simple circuit board (tier 1), the basic recipe is available (rank 1) and both higher-tier recipes are needs-research. Unit tests cover the empty-own-cost gating and the researched-prereq prune.
1 parent e9bd2fb commit e18679d

2 files changed

Lines changed: 121 additions & 33 deletions

File tree

app/src/db/queries.server.ts

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010
import { createHash } from "node:crypto";
1111
import { and, eq, inArray, isNotNull, sql, type AnyColumn } from "drizzle-orm";
12-
import { db } from "./index.server.ts";
12+
import { currentDatabaseFile, db } from "./index.server.ts";
1313
import {
1414
recipes,
1515
recipeIngredients,
@@ -2248,14 +2248,7 @@ export function stackBonuses(): StackBonuses {
22482248
const h = getResearchHorizon();
22492249
const out: StackBonuses = { belt: 0, inserter: 0, bulkInserter: 0 };
22502250
for (const r of db.select().from(techStackBonuses).all()) {
2251-
if (h.mode !== "future") {
2252-
const science = db
2253-
.select({ name: techIngredients.name })
2254-
.from(techIngredients)
2255-
.where(eq(techIngredients.technology, r.technology))
2256-
.all();
2257-
if (!techReachedByScience(r.technology, science, h)) continue;
2258-
}
2251+
if (h.mode !== "future" && !techReachedByScience(r.technology, h)) continue;
22592252
if (r.effect === "belt") out.belt += r.modifier;
22602253
else if (r.effect === "inserter") out.inserter += r.modifier;
22612254
else if (r.effect === "bulk-inserter") out.bulkInserter += r.modifier;
@@ -2279,14 +2272,7 @@ export function productivityBonuses(): ProductivityBonuses {
22792272
const h = getResearchHorizon();
22802273
const out: ProductivityBonuses = { mining: 0, recipes: new Map() };
22812274
for (const r of db.select().from(techProductivityBonuses).all()) {
2282-
if (h.mode !== "future") {
2283-
const science = db
2284-
.select({ name: techIngredients.name })
2285-
.from(techIngredients)
2286-
.where(eq(techIngredients.technology, r.technology))
2287-
.all();
2288-
if (!techReachedByScience(r.technology, science, h)) continue;
2289-
}
2275+
if (h.mode !== "future" && !techReachedByScience(r.technology, h)) continue;
22902276
if (r.recipe === "") out.mining += r.modifier;
22912277
else out.recipes.set(r.recipe, (out.recipes.get(r.recipe) ?? 0) + r.modifier);
22922278
}
@@ -2363,6 +2349,45 @@ function packsForTechs(techs: Set<string>): Set<string> {
23632349
);
23642350
}
23652351

2352+
/** A tech's full prerequisite closure (techs + the union of their science
2353+
* packs), memoized per active project db — the tech graph is static data, only
2354+
* changing on a data re-import (like `_horizonCache`, which caches the target's
2355+
* closure the same way). File-keyed so a project switch reads the right graph. */
2356+
const _closureCache = new Map<string, Map<string, { techs: Set<string>; packs: Set<string> }>>();
2357+
function techClosure(tech: string): { techs: Set<string>; packs: Set<string> } {
2358+
const file = currentDatabaseFile();
2359+
let byTech = _closureCache.get(file);
2360+
if (!byTech) _closureCache.set(file, (byTech = new Map()));
2361+
let e = byTech.get(tech);
2362+
if (!e) {
2363+
const techs = techPrereqClosure(tech);
2364+
byTech.set(tech, (e = { techs, packs: packsForTechs(techs) }));
2365+
}
2366+
return e;
2367+
}
2368+
2369+
/** Science packs still missing to reach `tech` under the horizon: the packs of
2370+
* its prerequisite closure MINUS what's already researched, minus what the
2371+
* horizon supplies. Empty = reachable. Checking the tech's OWN cost alone was
2372+
* wrong — a tech gated purely through prerequisites has an empty own cost (e.g.
2373+
* TURD-unlocked `neuron`), so it vacuously read as reachable at any tier; and in
2374+
* NOW mode a researched prerequisite shouldn't demand its pack again. */
2375+
function reachMissingPacks(tech: string, h: ResearchHorizon): string[] {
2376+
if (h.researched.has(tech)) return [];
2377+
const { techs, packs } = techClosure(tech);
2378+
// researched techs are prerequisite-closed, so dropping them from the closure
2379+
// prunes their (already-done) subtrees; only the unresearched frontier's packs
2380+
// must be supplied. Target mode has no researched set → the full closure.
2381+
const relevant = h.researched.size ? packsForTechs(setDiff(techs, h.researched)) : packs;
2382+
return [...relevant].filter((p) => !h.packs.has(p));
2383+
}
2384+
2385+
function setDiff(a: Set<string>, b: ReadonlySet<string>): Set<string> {
2386+
const out = new Set<string>();
2387+
for (const x of a) if (!b.has(x)) out.add(x);
2388+
return out;
2389+
}
2390+
23662391
/** The technology that first lets you make a good: among the techs unlocking a
23672392
* recipe that produces it, the lowest-tier one (fewest distinct science packs in
23682393
* its prerequisite closure, ties broken by name). null if it's start-craftable or
@@ -2547,15 +2572,12 @@ export function allSciencePacks(): string[] {
25472572
return packs.sort((a, b) => tier.get(a)! - tier.get(b)! || a.localeCompare(b));
25482573
}
25492574

2550-
/** A tech is "reached" if explicitly researched, or all its science packs are
2551-
* within your available set (you produce them, so you'll research it in time). */
2552-
function techReachedByScience(
2553-
tech: string,
2554-
science: { name: string }[],
2555-
h: ResearchHorizon,
2556-
): boolean {
2557-
if (h.researched.has(tech)) return true;
2558-
return science.every((s) => h.packs.has(s.name));
2575+
/** A tech is "reached" if explicitly researched, or every science pack in its
2576+
* full prerequisite closure is within your available set (you produce them, so
2577+
* you'll research it in time). See reachMissingPacks for why the closure — not
2578+
* the tech's own cost — is the correct gate. */
2579+
function techReachedByScience(tech: string, h: ResearchHorizon): boolean {
2580+
return reachMissingPacks(tech, h).length === 0;
25592581
}
25602582

25612583
/** TURD choice state for a sub-tech given current selections:
@@ -2603,14 +2625,14 @@ function computeAvail(
26032625
let research: RecipeAvail["research"];
26042626
let needs: string[] = [];
26052627
if (enabled) research = "enabled";
2606-
else if (unlocks.some((u) => techReachedByScience(u.tech, u.science, h))) research = "available";
26072628
else {
2608-
research = "needs-research";
2609-
needs = [
2610-
...new Set(
2611-
unlocks.flatMap((u) => u.science.map((s) => s.name)).filter((p) => !h.packs.has(p)),
2612-
),
2613-
];
2629+
// reachable via ANY unlocking tech; else the missing packs across all of them
2630+
const missing = unlocks.map((u) => reachMissingPacks(u.tech, h));
2631+
if (missing.some((m) => m.length === 0)) research = "available";
2632+
else {
2633+
research = "needs-research";
2634+
needs = [...new Set(missing.flat())];
2635+
}
26142636
}
26152637
const reached = research !== "needs-research";
26162638
// availableNow: a 'pickable' (researched-but-undecided) master counts — picking

app/src/db/queries.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
listGroups,
1616
machineSufficiency,
1717
productivityBonuses,
18+
recipeCandidates,
1819
saveBlockRow,
1920
setBlockGroup,
2021
setBuiltMachines,
@@ -350,3 +351,68 @@ describe("listBlocks health: sink goals need a consumer, not a producer", () =>
350351
expect(health(bad).unmadeGoals).toEqual(["plate"]);
351352
});
352353
});
354+
355+
describe("recipeCandidates availability: prerequisite-gated techs (empty own cost)", () => {
356+
const seed = () => {
357+
db.run(sql`
358+
INSERT INTO items (name, display) VALUES ('circuit','Circuit')
359+
`);
360+
db.run(sql`
361+
INSERT INTO recipes (name, kind, hidden, enabled) VALUES ('circuit-basic','real',0,0),('circuit-exotic','real',0,0)
362+
`);
363+
db.run(sql`
364+
INSERT INTO recipe_products (recipe, idx, kind, name, amount) VALUES
365+
('circuit-basic',0,'item','circuit',1),
366+
('circuit-exotic',0,'item','circuit',1)
367+
`);
368+
db.run(sql`
369+
INSERT INTO technologies (name, display) VALUES ('t-basic','Basic'),('t-exotic','Exotic'),('t-prereq','Prereq')
370+
`);
371+
db.run(sql`
372+
INSERT INTO tech_ingredients (technology, name, amount) VALUES
373+
('t-basic','automation-science-pack',1),
374+
('t-prereq','py-science-pack-1',1)
375+
`);
376+
// t-exotic has NO own science cost — it's gated purely through its prerequisite
377+
db.run(sql`INSERT INTO tech_prerequisites (technology, prerequisite) VALUES ('t-exotic','t-prereq')`);
378+
db.run(sql`
379+
INSERT INTO tech_unlocks (technology, recipe) VALUES
380+
('t-basic','circuit-basic'),
381+
('t-exotic','circuit-exotic')
382+
`);
383+
};
384+
385+
it("a tech with empty own cost is gated by its prerequisites, not vacuously reachable", () => {
386+
seed();
387+
// the horizon supplies only automation science (the basic tier)
388+
setResearchHorizon({ mode: "now", packs: ["automation-science-pack"], researched: [] });
389+
const cands = recipeCandidates("circuit", "produce");
390+
const basic = cands.find((c) => c.name === "circuit-basic")!;
391+
const exotic = cands.find((c) => c.name === "circuit-exotic")!;
392+
// basic (automation only) is available now
393+
expect(basic.avail.research).toBe("available");
394+
expect(basic.avail.availableNow).toBe(true);
395+
// exotic's unlocking tech has an EMPTY own cost but its prereq needs
396+
// py-science-1 — before the fix [].every() made it vacuously "available"
397+
expect(exotic.avail.research).toBe("needs-research");
398+
expect(exotic.avail.availableNow).toBe(false);
399+
expect(exotic.avail.needs).toContain("py-science-pack-1");
400+
// so the basic recipe ranks ABOVE the exotic one
401+
expect(cands.findIndex((c) => c.name === "circuit-basic")).toBeLessThan(
402+
cands.findIndex((c) => c.name === "circuit-exotic"),
403+
);
404+
});
405+
406+
it("a researched prerequisite is not re-demanded (NOW mode)", () => {
407+
seed();
408+
// you produce only automation science, but t-prereq is already researched →
409+
// t-exotic is now reachable (its remaining frontier costs nothing you lack)
410+
setResearchHorizon({
411+
mode: "now",
412+
packs: ["automation-science-pack"],
413+
researched: ["t-prereq"],
414+
});
415+
const exotic = recipeCandidates("circuit", "produce").find((c) => c.name === "circuit-exotic")!;
416+
expect(exotic.avail.research).toBe("available");
417+
});
418+
});

0 commit comments

Comments
 (0)