Skip to content

Commit d55f13e

Browse files
committed
Merge branch 'worktree-agent-a67a9f69f4b9c2bca'
# Conflicts: # src/components/designs/genesis/timeline.ts # src/components/designs/vale/art.ts
2 parents 73e9ec0 + d1b9bc8 commit d55f13e

4 files changed

Lines changed: 679 additions & 6 deletions

File tree

src/components/designs/genesis/daytype.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)