@@ -471,3 +471,221 @@ export function festivalFire(map: GenesisMap): Vec2 | null {
471471 }
472472 return best ? [ Math . round ( best [ 0 ] * 4 ) / 4 , Math . round ( best [ 1 ] * 4 ) / 4 ] : null ;
473473}
474+
475+ /* ========================================================================== *
476+ * THE PROSPECTOR, and the one day in twenty he finds something (additive) *
477+ * -------------------------------------------------------------------------- *
478+ * Two more pure questions about a seed, on the same terms as everything above
479+ * them: derived streams, DOM-free, and answered identically under bare node and
480+ * in the browser. Nothing here touches the day-type lottery — a gold-strike day
481+ * is orthogonal to the weather and composes with any of it. A market day can be
482+ * a gold day; so can a storm.
483+ *
484+ * They live in this module rather than in the renderer because the TIMELINE
485+ * needs one of them: the ledger names the town nearest the strike, and the
486+ * ledger is built under bare node with no canvas in sight. `scene.ts` and
487+ * `timeline.ts` both import from here already, which is what keeps the two of
488+ * them agreeing about which town wakes up rich.
489+ *
490+ * The prospector's position is an ANALYTIC function of the hour — no
491+ * integration, no accumulated state, no reset hook. That is the whole reason a
492+ * scrub works: at 11:20 he is where 11:20 puts him, whether the visitor got
493+ * there by watching or by dragging the scrubber backwards through the morning.
494+ * ========================================================================== */
495+
496+ /** Salts. Distinct from every other salt in the codebase, deliberately. */
497+ const SALT_PAN = 0x7ba17bed ;
498+ const SALT_GOLD = 0x901dd05e ;
499+
500+ /** His working day: on the water a little before the crews, off it at dusk. */
501+ const PAN_FROM = 6.4 ;
502+ const PAN_TO = 19.4 ;
503+ /** How much of each bar's slot he spends kneeling rather than wading on. */
504+ const PAN_DWELL = 0.74 ;
505+ /** Tiles of river he works between dawn and dusk. */
506+ const PAN_MIN = 10 ;
507+ const PAN_MAX = 20 ;
508+
509+ /** One seed in twenty turns up colour. */
510+ const GOLD_CHANCE = 1 / 20 ;
511+ /** …and never before the afternoon, never after the light starts going. */
512+ export const GOLD_FROM = 13.0 ;
513+ export const GOLD_TO = 16.5 ;
514+
515+ /**
516+ * The beat he works: a stretch of one bank, and the arclength table that turns
517+ * an hour into a place on it.
518+ */
519+ export interface ProspectorPath {
520+ pts : Vec2 [ ] ;
521+ cum : number [ ] ;
522+ len : number ;
523+ /** Arclength he starts the day at, and the signed distance he covers. */
524+ s0 : number ;
525+ span : number ;
526+ /** Which bank he kneels on: the sign of the river normal he stands off. */
527+ side : 1 | - 1 ;
528+ /** How many gravel bars he works between dawn and dusk. */
529+ bars : number ;
530+ /** How far off the centreline he kneels, in tiles. */
531+ off : number ;
532+ /** A colour for his coat, picked once. */
533+ color : string ;
534+ }
535+
536+ /** Where he is, and what he is doing about it. */
537+ export interface ProspectorPose {
538+ gx : number ;
539+ gy : number ;
540+ /** 1 kneeling at a bar, 0 wading to the next one. */
541+ work : 0 | 1 ;
542+ /** Facing right on screen. */
543+ face : boolean ;
544+ }
545+
546+ function panCum ( pts : Vec2 [ ] ) : { cum : number [ ] ; len : number } {
547+ const cum = [ 0 ] ;
548+ for ( let i = 1 ; i < pts . length ; i ++ ) {
549+ cum . push ( cum [ i - 1 ] + Math . hypot ( pts [ i ] [ 0 ] - pts [ i - 1 ] [ 0 ] , pts [ i ] [ 1 ] - pts [ i - 1 ] [ 1 ] ) ) ;
550+ }
551+ return { cum, len : cum [ cum . length - 1 ] || 1 } ;
552+ }
553+
554+ /** Point and unit tangent at arclength `s` along a polyline. */
555+ function panPoint (
556+ pts : Vec2 [ ] ,
557+ cum : number [ ] ,
558+ len : number ,
559+ s : number
560+ ) : [ number , number , number , number ] {
561+ const d = s < 0 ? 0 : s > len ? len : s ;
562+ let i = 1 ;
563+ while ( i < cum . length - 1 && cum [ i ] < d ) i ++ ;
564+ const seg = cum [ i ] - cum [ i - 1 ] || 1 ;
565+ const f = ( d - cum [ i - 1 ] ) / seg ;
566+ const ax = pts [ i - 1 ] [ 0 ] ;
567+ const ay = pts [ i - 1 ] [ 1 ] ;
568+ const dx = pts [ i ] [ 0 ] - ax ;
569+ const dy = pts [ i ] [ 1 ] - ay ;
570+ const L = Math . hypot ( dx , dy ) || 1 ;
571+ return [ ax + dx * f , ay + dy * f , dx / L , dy / L ] ;
572+ }
573+
574+ /** Coats, a subset of the crowd's own palette so he reads as one of them. */
575+ const PAN_COLORS = [ '#c8a86a' , '#9aa3ad' , '#a9743e' , '#6cc4d9' ] ;
576+
577+ /**
578+ * The stretch of river this valley's prospector works today, or null if there
579+ * is no river worth kneeling in.
580+ *
581+ * "Upstream" is taken to be whichever end of the river sits further UP THE
582+ * SCREEN. There is no flow direction in the map data and there does not need to
583+ * be one: in an isometric valley the far end reads as the head of the water, so
584+ * walking towards it reads as walking upstream, which is the only thing this
585+ * has to get right.
586+ */
587+ export function prospectorPath ( map : GenesisMap ) : ProspectorPath | null {
588+ const pts = map . river ?? [ ] ;
589+ if ( pts . length < 2 ) return null ;
590+ const { cum, len } = panCum ( pts ) ;
591+ if ( len < 6 ) return null ;
592+
593+ const rng = mulberry32 ( ( ( map . seed >>> 0 ) ^ SALT_PAN ) >>> 0 ) ;
594+ // isoY is monotone in (gx + gy), so this comparison is the screen one.
595+ const head = pts [ 0 ] ;
596+ const tail = pts [ pts . length - 1 ] ;
597+ const up = tail [ 0 ] + tail [ 1 ] < head [ 0 ] + head [ 1 ] ? 1 : - 1 ;
598+
599+ const lo = len * 0.06 ;
600+ const hi = len * 0.94 ;
601+ const room = Math . max ( 1 , hi - lo ) ;
602+ const dist = Math . min ( PAN_MIN + rng ( ) * ( PAN_MAX - PAN_MIN ) , room ) ;
603+ const slack = Math . max ( 0 , room - dist ) ;
604+ const s0 = up > 0 ? lo + rng ( ) * slack : lo + dist + rng ( ) * slack ;
605+
606+ return {
607+ pts,
608+ cum,
609+ len,
610+ s0,
611+ span : up * dist ,
612+ side : rng ( ) < 0.5 ? 1 : - 1 ,
613+ bars : 8 + Math . floor ( rng ( ) * 5 ) ,
614+ // Half the channel plus a boot's width, so he is in the shallows at the
615+ // edge of the water rather than out in the middle of the river.
616+ off : ( map . riverWidth ?? 0.95 ) * 0.5 + 0.45 ,
617+ color : PAN_COLORS [ Math . floor ( rng ( ) * PAN_COLORS . length ) ] ,
618+ } ;
619+ }
620+
621+ /**
622+ * Where he is at hour `t`, or null when he is not out.
623+ *
624+ * The day is cut into `bars` equal slots. He spends the first three quarters of
625+ * each one kneeling in the same place and the rest of it wading up to the next,
626+ * which from across the valley is a man who moves about once an hour and
627+ * otherwise does not move at all — which is what panning looks like.
628+ */
629+ export function prospectorAt ( path : ProspectorPath , t : number ) : ProspectorPose | null {
630+ if ( t < PAN_FROM || t >= PAN_TO ) return null ;
631+ const k = ( t - PAN_FROM ) / ( PAN_TO - PAN_FROM ) ;
632+ const u = k * path . bars ;
633+ const i = Math . min ( path . bars - 1 , Math . floor ( u ) ) ;
634+ const f = u - i ;
635+ let step = i ;
636+ if ( f > PAN_DWELL ) {
637+ const e = ( f - PAN_DWELL ) / ( 1 - PAN_DWELL ) ;
638+ step = i + e * e * ( 3 - 2 * e ) ; // smoothstep: he sets off and settles again
639+ }
640+ const s = path . s0 + ( path . span * step ) / path . bars ;
641+ const [ px , py , tx , ty ] = panPoint ( path . pts , path . cum , path . len , s ) ;
642+ const nx = - ty * path . side ;
643+ const ny = tx * path . side ;
644+ // Screen x runs with (gx - gy), so that is the sign that decides which way he
645+ // is facing while he wades.
646+ const dir = path . span > 0 ? 1 : - 1 ;
647+ return {
648+ gx : px + nx * path . off ,
649+ gy : py + ny * path . off ,
650+ work : f > PAN_DWELL ? 0 : 1 ,
651+ face : ( tx - ty ) * dir > 0 ,
652+ } ;
653+ }
654+
655+ /** The day's strike: when, where, and who is going to hear about it. */
656+ export interface GoldStrike {
657+ at : number ;
658+ gx : number ;
659+ gy : number ;
660+ site : SiteSpec | null ;
661+ }
662+
663+ /**
664+ * Is this one of the days? One seed in twenty, off its own stream, so it can
665+ * land on a mist day, a market day or an ordinary Tuesday with equal ease and
666+ * without moving a single tree, stall or cloud on any of them.
667+ */
668+ export function goldStrike ( map : GenesisMap ) : GoldStrike | null {
669+ const rng = mulberry32 ( ( ( map . seed >>> 0 ) ^ SALT_GOLD ) >>> 0 ) ;
670+ if ( rng ( ) >= GOLD_CHANCE ) return null ;
671+ const at = GOLD_FROM + rng ( ) * ( GOLD_TO - GOLD_FROM ) ;
672+ const path = prospectorPath ( map ) ;
673+ if ( ! path ) return null ;
674+ const pose = prospectorAt ( path , at ) ;
675+ if ( ! pose ) return null ;
676+
677+ // The nearest town to the gravel bar, with the id as an explicit tie-break so
678+ // the answer never depends on array iteration luck.
679+ let site : SiteSpec | null = null ;
680+ let best = Infinity ;
681+ for ( const s of map . sites ) {
682+ const d = Math . hypot ( s . gx - pose . gx , s . gy - pose . gy ) ;
683+ if ( d < best || ( d === best && site && s . id < site . id ) ) {
684+ best = d ;
685+ site = s ;
686+ }
687+ }
688+ return { at, gx : pose . gx , gy : pose . gy , site } ;
689+ }
690+
691+ /* ---- end the prospector (additive) --------------------------------------- */
0 commit comments