99 */
1010import { createHash } from "node:crypto" ;
1111import { 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" ;
1313import {
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
0 commit comments