basin / commit
Fix board-generation hang on sparse boards; add Reach axis; keep springs off shore cells
index.html | 7 ++ readme.md | 34 +++++++--- src/main.ts | 17 ++++- src/model/puzzle.test.ts | 111 +++++++++++++++++++++++++++++++- src/model/puzzle.ts | 163 +++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 294 insertions(+), 38 deletions(-) diff --git a/index.html b/index.html index 81e6f2f..bd9a190 100644 --- a/index.html +++ b/index.html @@ -45,6 +45,13 @@ </span> <input type="range" id="cfg-erosion" min="0" max="15" step="1" /> </label> + <label class="ctl" title="How far from the shore springs are drawn from. Low favours land just above the waterline; high favours high ground near the peak."> + <span class="ctl-head"> + <span>Reach <span id="cfg-reach-val" class="val"></span></span> + <button type="button" class="lock" data-lock="reach" aria-pressed="false">🔓</button> + </span> + <input type="range" id="cfg-reach" min="0" max="15" step="1" /> + </label> <label class="ctl"> <span class="ctl-head"> <span>Terrain</span> diff --git a/readme.md b/readme.md index be31e3a..576b940 100644 --- a/readme.md +++ b/readme.md @@ -15,7 +15,8 @@ Each board has a heightmap that falls to the sea at its **ocean** sides. Water released from a *spring* flows downhill, branching to every strictly-lower neighbour, and exits wherever it reaches an ocean edge. Every board is generated with a hidden set of springs; their combined flow lights up a **target edge -signature** (the gold-outlined border cells). +signature**, shown as gold outlines on the *shore* — the land cells immediately +inland of each wet ocean edge, rather than the ocean cells themselves. Each of the four sides is independently rolled **ocean** (blue — water exits) or a **mountain wall** (high ground that blocks water). Walls make canyons: e.g. @@ -26,7 +27,16 @@ The terrain can also be **aged**: an *erosion* amount simulates how much weathering happened before you arrived. At 0 the board is a pristine dome; turn it up and simulated hydraulic erosion carves grooves and channels, ragging the coastline and moving where water finally reaches the sea — so a heavily-aged -board reads a visibly different edge signature from its pristine self. +board reads a visibly different edge signature from its pristine self. Erosion +is self-limiting: if a particular seed would wash away too much standing land, +generation automatically backs off the effective aging for that board so it +never degenerates into an almost-all-water board. + +**Reach** controls how far from the shore the hidden springs are drawn from: +low values favour land just above the waterline, high values favour high +ground near the peak, and the midpoint has no preference (the original +behaviour). This only shapes which legible answer generation reaches for — it +never affects the unambiguous-solution guarantee below. ## How to play @@ -41,16 +51,22 @@ gold-edge hint always pins down a single answer (no redundant or interchangeable springs). Boards that can't be made unique fall back to the richest signature found, so a board is always produced. +Springs also never sit on a shore cell — the gold outline never doubles as a +giveaway for one of the hidden positions. (Only an extreme combination, like a +5×5 board with 6 springs, can make this strictly impossible; generation falls +back gracefully there rather than failing.) + ## Configuring & sharing boards Every axis of a board is a control in the panel: **size**, **spring count**, which -sides are **walls**, the **aging** (erosion) amount, and the **terrain seed**. All -of it is captured in a single shareable **code** — an 8-character token (e.g. -`B0B0bgTL`) that packs the five axes into 48 bits and base64url-encodes them, so -the settings aren't legible at a glance (reversible obfuscation, not encryption). -The code and the controls are two-way views of the same config — changing any -control rewrites the code, and pasting a code sets every control. Older codes -predate the aging axis and simply decode to a pristine (un-eroded) board. +sides are **walls**, the **aging** (erosion) amount, **reach**, and the **terrain +seed**. All of it is captured in a single shareable **code** — a 9-character token +(e.g. `B0B0bgTL9`) that packs the six axes into 52 bits and base64url-encodes +them, so the settings aren't legible at a glance (reversible obfuscation, not +encryption). The code and the controls are two-way views of the same config — +changing any control rewrites the code, and pasting a code sets every control. +The reach axis grew the code by a character, so pre-reach 8-character codes no +longer decode — a stale shared link just rolls a fresh board instead. The code also lives in the URL as `?code=<code>`, so a link reproduces the exact board. **🎲 Random** rolls a whole new config; the 🎲 next to Terrain rerolls just diff --git a/src/main.ts b/src/main.ts index 5afee59..d0194de 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,8 @@ const sizeVal = $<HTMLElement>('#cfg-size-val'); const springsInput = $<HTMLInputElement>('#cfg-springs'); const erosionInput = $<HTMLInputElement>('#cfg-erosion'); const erosionVal = $<HTMLElement>('#cfg-erosion-val'); +const reachInput = $<HTMLInputElement>('#cfg-reach'); +const reachVal = $<HTMLElement>('#cfg-reach-val'); const wall = { n: $<HTMLInputElement>('#cfg-wall-n'), e: $<HTMLInputElement>('#cfg-wall-e'), @@ -62,6 +64,8 @@ function syncControls(): void { springsInput.value = String(config.springCount); erosionInput.value = String(config.erosion); erosionVal.textContent = agingLabel(config.erosion); + reachInput.value = String(config.reach); + reachVal.textContent = reachLabel(config.reach); wall.n.checked = config.edges.n === 'wall'; wall.e.checked = config.edges.e === 'wall'; wall.s.checked = config.edges.s === 'wall'; @@ -87,12 +91,13 @@ const edgeOf = (checked: boolean) => (checked ? 'wall' : 'ocean'); // Which config axes are pinned against Randomize. Session-only: locks aren't part // of a board (the code encodes the config, not what you froze while rolling). -type LockAxis = 'size' | 'springCount' | 'edges' | 'erosion' | 'terrainSeed'; +type LockAxis = 'size' | 'springCount' | 'edges' | 'erosion' | 'reach' | 'terrainSeed'; const locks: Record<LockAxis, boolean> = { size: false, springCount: false, edges: false, erosion: false, + reach: false, terrainSeed: false, }; @@ -121,6 +126,7 @@ function randomizeUnlocked(): Config { springCount: locks.springCount ? config.springCount : r.springCount, edges: locks.edges ? { ...config.edges } : r.edges, erosion: locks.erosion ? config.erosion : r.erosion, + reach: locks.reach ? config.reach : r.reach, terrainSeed: locks.terrainSeed ? config.terrainSeed : r.terrainSeed, }; } @@ -131,6 +137,12 @@ function agingLabel(n: number): string { return `${n} · ${word}`; } +/** Human tag for a reach level, so the slider reads as more than a number. */ +function reachLabel(n: number): string { + const word = n <= 3 ? 'shore' : n <= 11 ? 'mixed' : 'peaks'; + return `${n} · ${word}`; +} + // Live-update the size label while dragging, but only rebuild on release. sizeInput.addEventListener('input', () => (sizeVal.textContent = `${sizeInput.value}×${sizeInput.value}`)); sizeInput.addEventListener('change', () => apply({ ...config, size: Number(sizeInput.value) })); @@ -141,6 +153,9 @@ springsInput.addEventListener('change', () => // on release. erosionInput.addEventListener('input', () => (erosionVal.textContent = agingLabel(Number(erosionInput.value)))); erosionInput.addEventListener('change', () => apply({ ...config, erosion: Number(erosionInput.value) })); +// Live-update the reach label while dragging, but only rebuild on release. +reachInput.addEventListener('input', () => (reachVal.textContent = reachLabel(Number(reachInput.value)))); +reachInput.addEventListener('change', () => apply({ ...config, reach: Number(reachInput.value) })); for (const side of ['n', 'e', 's', 'w'] as const) { wall[side].addEventListener('change', () => apply({ ...config, edges: { ...config.edges, [side]: edgeOf(wall[side].checked) } }), diff --git a/src/model/puzzle.test.ts b/src/model/puzzle.test.ts index bd7383b..faaf497 100644 --- a/src/model/puzzle.test.ts +++ b/src/model/puzzle.test.ts @@ -11,10 +11,13 @@ import { SPRINGS_MIN, SPRINGS_MAX, EROSION_MAX, + REACH_MIN, + REACH_MAX, + REACH_DEFAULT, } from './puzzle'; import type { Config } from './puzzle'; import { simulateFlows } from './flow'; -import { isOceanEdge, ALL_OCEAN } from './heightmap'; +import { heightAt, isOceanEdge, ALL_OCEAN } from './heightmap'; import type { Edges } from './heightmap'; const SIZE = 12; @@ -26,6 +29,7 @@ const cfg = (over: Partial<Config> = {}): Config => ({ springCount: COUNT, edges: ALL_OCEAN, erosion: 0, + reach: REACH_DEFAULT, terrainSeed: 4242, ...over, }); @@ -143,17 +147,21 @@ describe('config code', () => { ]; for (const c of configs) { const code = encodeConfig(c); - expect(code).toHaveLength(8); + expect(code).toHaveLength(9); expect(decodeConfig(code)).toEqual(normalizeConfig(c)); } }); it('returns null for codes of the wrong length or alphabet', () => { - for (const bad of ['', 'abc', 'AAAAAAA', 'AAAAAAAAA', '!!!!!!!!', 'BQAr3k f']) { + for (const bad of ['', 'abc', 'AAAAAAAA', 'AAAAAAAAAA', '!!!!!!!!!', 'BQAr3k9 f']) { expect(decodeConfig(bad)).toBeNull(); } }); + it('rejects the pre-reach 8-char code format (breaking format bump)', () => { + expect(decodeConfig('BQAr3kf9')).toBeNull(); + }); + it('normalises out-of-range and all-wall configs', () => { const clamped = normalizeConfig( cfg({ size: 999, springCount: 999, edges: { n: 'wall', e: 'wall', s: 'wall', w: 'wall' } }), @@ -177,6 +185,14 @@ describe('config code', () => { delete (legacy as Partial<Config>).erosion; expect(normalizeConfig(legacy).erosion).toBe(0); }); + + it('clamps reach into range and defaults a missing value to REACH_DEFAULT', () => { + expect(normalizeConfig(cfg({ reach: 999 })).reach).toBe(REACH_MAX); + expect(normalizeConfig(cfg({ reach: -5 })).reach).toBe(REACH_MIN); + const legacy = cfg(); + delete (legacy as Partial<Config>).reach; + expect(normalizeConfig(legacy).reach).toBe(REACH_DEFAULT); + }); }); describe('erosion (aging)', () => { @@ -211,6 +227,95 @@ describe('erosion (aging)', () => { }); }); +describe('reach', () => { + it('biases spring elevation toward shore (low) or peak (high)', () => { + // Average solution height across many seeds should rise monotonically-ish + // from low reach (near shore) to high reach (near peak). + const meanHeight = (reach: number): number => { + let total = 0; + let n = 0; + for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) { + const p = generatePuzzle(cfg({ size: 16, springCount: 3, reach, terrainSeed: seed })); + for (const s of p.solution) { + total += heightAt(p.hm, s.x, s.y); + n++; + } + } + return total / n; + }; + + const low = meanHeight(REACH_MIN); + const high = meanHeight(REACH_MAX); + expect(low).toBeLessThan(high); + }); + + it('still produces a full, distinct spring set at both extremes', () => { + for (const reach of [REACH_MIN, REACH_MAX]) { + for (const seed of [1, 42, 12345]) { + const p = generatePuzzle(cfg({ size: 14, springCount: 4, reach, terrainSeed: seed })); + expect(p.solution).toHaveLength(4); + expect(new Set(p.solution.map((c) => `${c.x},${c.y}`)).size).toBe(4); + } + } + }); +}); + +describe('springs never land on a shore (target-indicator) cell', () => { + const isShore = (c: { x: number; y: number }, edges: Edges, size: number): boolean => + ([[c.x - 1, c.y], [c.x + 1, c.y], [c.x, c.y - 1], [c.x, c.y + 1]] as const).some(([nx, ny]) => + isOceanEdge(edges, nx, ny, size, size), + ); + + it('holds across realistic (randomConfig-range) boards, including reach 0', () => { + // Reach 0 (shore-biased) is exactly the setting most likely to collide + // with the gold target indicator, which the renderer also draws on shore + // cells — so this is the sharpest check that the exclusion is working. + for (const seed of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { + const size = 8 + (seed % 5) * 2; // 8..16 + const p = generatePuzzle(cfg({ size, springCount: 4, reach: REACH_MIN, terrainSeed: seed })); + for (const s of p.solution) expect(isShore(s, p.edges, size)).toBe(false); + } + }); + + it('holds at every reach setting on a mid-sized board', () => { + for (let reach = REACH_MIN; reach <= REACH_MAX; reach++) { + const p = generatePuzzle(cfg({ size: 16, springCount: 3, reach, terrainSeed: 777 })); + for (const s of p.solution) expect(isShore(s, p.edges, 16)).toBe(false); + } + }); + + it('still fills springCount even when the non-shore interior is too small to avoid it', () => { + // size 5 → interior 3x3, only the centre cell is non-shore; springCount 6 + // structurally can't be satisfied without some shore cells. + const p = generatePuzzle(cfg({ size: 5, springCount: 6, terrainSeed: 1 })); + expect(p.solution).toHaveLength(6); + expect(new Set(p.solution.map((c) => `${c.x},${c.y}`)).size).toBe(6); + }); +}); + +describe('land sparsity', () => { + it('never leaves a board where land candidates fall short of springCount', () => { + // Heavy erosion can flatten most of the interior; generation must still + // land on a full, distinct spring set rather than crashing or degenerating. + for (const size of [5, 8, 12]) { + for (const springCount of [1, 4, 6]) { + for (const seed of [1, 2, 3, 4, 5]) { + const p = generatePuzzle(cfg({ size, springCount, erosion: EROSION_MAX, terrainSeed: seed })); + expect(p.solution).toHaveLength(Math.min(springCount, (size - 2) * (size - 2))); + expect(new Set(p.solution.map((c) => `${c.x},${c.y}`)).size).toBe(p.solution.length); + } + } + } + }); + + it('regression: the exact config that used to crash generation now succeeds', () => { + // size 8, springCount 4, erosion 7, seed 2521858826 — bestAny stayed null + // pre-fix because only 3 cells cleared the land threshold for 4 springs. + const p = generatePuzzle(cfg({ size: 8, springCount: 4, erosion: 7, terrainSeed: 2521858826 })); + expect(p.solution).toHaveLength(4); + }); +}); + describe('randomConfig', () => { it('stays within bounds with at least one ocean side', () => { for (let i = 0; i < 200; i++) { diff --git a/src/model/puzzle.ts b/src/model/puzzle.ts index 6c800a4..cdd43da 100644 --- a/src/model/puzzle.ts +++ b/src/model/puzzle.ts @@ -1,6 +1,6 @@ import { mulberry32 } from '../rng'; import type { Rng } from '../rng'; -import { generateDome, heightAt } from './heightmap'; +import { generateDome, heightAt, isOceanEdge } from './heightmap'; import type { Edges, EdgeType, Heightmap } from './heightmap'; import { simulateFlows } from './flow'; import type { Cell } from './flow'; @@ -23,6 +23,12 @@ export interface Config { * the outflow signature. See {@link erode}. */ erosion: number; + /** + * How far from the shore springs are drawn from: low values favour land just + * above the waterline, high values favour high ground near the peak. 0-15, + * centred at {@link REACH_DEFAULT} (no preference — the original behaviour). + */ + reach: number; /** Seed for the terrain noise + spring placement (given the other axes). */ terrainSeed: number; } @@ -33,6 +39,9 @@ export const SPRINGS_MIN = 1; export const SPRINGS_MAX = 6; export const EROSION_MIN = 0; export const EROSION_MAX = 15; // fits the 4 spare bits of the config code +export const REACH_MIN = 0; +export const REACH_MAX = 15; +export const REACH_DEFAULT = 8; /** * A solvable board (Reading A): terrain + a target edge signature are given, and @@ -71,9 +80,22 @@ export function generatePuzzle( ): Puzzle { onProgress?.(0); const norm = normalizeConfig(config); - const { size, springCount, edges, erosion, terrainSeed } = norm; + const { size, springCount, edges, erosion, reach, terrainSeed } = norm; const rng = mulberry32(terrainSeed); - const hm = generateDome(size, size, rng, edges, erosion); + let hm = generateDome(size, size, rng, edges, erosion); + + // Heavy erosion (or just an unlucky seed) can wash away most standing land, + // leaving a board that reads as almost entirely water — not a fair puzzle. + // Back off the *effective* aging in fixed steps, each redrawn from a seed + // derived from terrainSeed (so the fallback is itself deterministic), until + // enough land remains. Boards that already clear the bar take zero extra + // steps and are unaffected. + const interior = (size - 2) * (size - 2); + const minLand = Math.min(interior, Math.max(springCount, Math.ceil(interior * 0.2))); + for (let step = 1; landCandidates(hm, size).length < minLand && step <= 6; step++) { + const backoffRng = mulberry32((terrainSeed + step * 0x9e3779b9) >>> 0); + hm = generateDome(size, size, backoffRng, edges, erosion * Math.pow(0.7, step)); + } onProgress?.(0.1); // Candidates: interior land cells with enough elevation to render visibly as @@ -81,13 +103,63 @@ export function generatePuzzle( // height > 0, but very shallow cells near ocean edges can still look like // water in the terrain colours — a spring there would be unclickable. // 0.1 sits comfortably in the water→land colour transition (0.02–0.15). - const candidates: Cell[] = []; - for (let y = 1; y < size - 1; y++) { - for (let x = 1; x < size - 1; x++) { - if (heightAt(hm, x, y) >= 0.1) candidates.push({ x, y }); + const candidates = landCandidates(hm, size); + + // The backoff above targets the common case; guarantee the invariant outright + // by back-filling with the highest remaining interior cells if it's still + // short. Interior cell count is always >= springCount (normalizeConfig + // guarantees it), so this always reaches springCount candidates. + if (candidates.length < springCount) { + const seen = new Set(candidates.map((c) => c.y * size + c.x)); + const rest: Cell[] = []; + for (let y = 1; y < size - 1; y++) { + for (let x = 1; x < size - 1; x++) { + if (!seen.has(y * size + x)) rest.push({ x, y }); + } } + rest.sort((a, b) => heightAt(hm, b.x, b.y) - heightAt(hm, a.x, a.y)); + candidates.push(...rest.slice(0, springCount - candidates.length)); } + // Springs must never land on a "shore" cell — an interior cell directly + // adjacent to an ocean edge — because that's exactly where the renderer + // draws the gold target outline (see Renderer.drawTarget): a spring there + // would double as its own goal indicator, giving it away. `pickSpringIdx` + // below exhausts non-shore candidates before ever touching `shoreIdx`, so + // shore cells are used only for the picks that genuinely can't be filled + // otherwise (tiny boards with springCount larger than the non-shore + // interior can support) — never more than the unavoidable minimum. + const nonShoreIdx = candidates.map((_, i) => i).filter((i) => !isShoreCell(candidates[i], edges, size)); + const shoreIdx = candidates.map((_, i) => i).filter((i) => isShoreCell(candidates[i], edges, size)); + + // Reach then biases which non-shore candidates get *proposed* as springs: + // low values favour land just above the shoreline (but still off it), high + // values favour high ground near the peak. Height is a direct proxy for + // distance from the ocean edge in this dome model (see generateDome). This + // only narrows the proposal pool, not the uniqueness check below — `sig` + // covers every real candidate, so a spring set outside the reach band is + // still counted as a competing solution. That way "Reach" never makes the + // gold-edge hint ambiguous, it just shapes which legible answer generation + // reaches for. A hard band keeps the effect legible; if it would leave too + // few candidates to place springCount springs, fall back to the full + // non-shore pool. + const REACH_BAND = 0.5; // fraction of the sorted pool kept at any reach setting + const byHeight = [...nonShoreIdx].sort( + (a, b) => heightAt(hm, candidates[a].x, candidates[a].y) - heightAt(hm, candidates[b].x, candidates[b].y), + ); + const bandStart = (reach / REACH_MAX) * (1 - REACH_BAND); + const lo = Math.floor(bandStart * byHeight.length); + const hi = Math.min(byHeight.length, lo + Math.ceil(REACH_BAND * byHeight.length)); + const windowed = byHeight.slice(lo, hi); + const reachPool = windowed.length >= springCount ? windowed : nonShoreIdx; + + // Only ever hand pickSpringIdx a shore tier if non-shore candidates can't + // reach springCount at all. Otherwise every attempt's minGap relaxation + // (below) already finds a spaced non-shore set on its own by the time it's + // fully relaxed — gating here stops an early, still-strict attempt from + // reaching into shore just to patch a spacing gap it didn't need to. + const springPools = nonShoreIdx.length >= springCount ? [reachPool] : [nonShoreIdx, shoreIdx]; + // A candidate's outflow footprint is fixed by the terrain, so precompute each // one once as a bitmask over border-cell indices. Because wet sets (and hence // outflow) union across springs, a spring set's target signature is simply the @@ -128,7 +200,7 @@ export function generatePuzzle( onProgress?.(P_SIG_END + (0.99 - P_SIG_END) * Math.min(0.98, elapsed / attemptsEstMs)); // Relax the spacing requirement if the board is too cramped to satisfy it. const minGap = attempt < 96 ? 3 : attempt < 112 ? 2 : 1; - const idx = pickSpringIdx(candidates, springCount, rng, minGap); + const idx = pickSpringIdx(candidates, springPools, springCount, rng, minGap); if (idx.length < springCount) continue; let target = 0n; @@ -168,6 +240,28 @@ export function generatePuzzle( }; } +/** Is `c` directly adjacent to an ocean edge — a "shore" cell (see Renderer.drawTarget)? */ +function isShoreCell(c: Cell, edges: Edges, size: number): boolean { + const neighbors: ReadonlyArray<readonly [number, number]> = [ + [c.x - 1, c.y], + [c.x + 1, c.y], + [c.x, c.y - 1], + [c.x, c.y + 1], + ]; + return neighbors.some(([nx, ny]) => isOceanEdge(edges, nx, ny, size, size)); +} + +/** Interior cells whose height clears the "looks like land" threshold. */ +function landCandidates(hm: Heightmap, size: number): Cell[] { + const out: Cell[] = []; + for (let y = 1; y < size - 1; y++) { + for (let x = 1; x < size - 1; x++) { + if (heightAt(hm, x, y) >= 0.1) out.push({ x, y }); + } + } + return out; +} + /** A spring set under consideration during generation, with its signature width. */ interface Candidate { solution: Cell[]; @@ -193,11 +287,13 @@ export function normalizeConfig(c: Config): Config { const interior = (size - 2) * (size - 2); const springCount = clampInt(c.springCount, SPRINGS_MIN, Math.min(SPRINGS_MAX, interior)); const erosion = clampInt(c.erosion ?? 0, EROSION_MIN, EROSION_MAX); + const reach = clampInt(c.reach ?? REACH_DEFAULT, REACH_MIN, REACH_MAX); return { size, springCount, edges: ensureOcean(c.edges), erosion, + reach, terrainSeed: c.terrainSeed >>> 0, }; } @@ -209,19 +305,22 @@ export function randomConfig(): Config { springCount: 2 + Math.floor(Math.random() * 3), // 2,3,4 edges: rollEdges(Math.random), erosion: Math.floor(Math.random() * 9), // 0..8: pristine to well-weathered + reach: Math.floor(Math.random() * (REACH_MAX + 1)), terrainSeed: (Math.random() * 2 ** 32) >>> 0, }); } -// The config packs into 48 bits — seed(32) · edges(4) · springs(3) · size(5) · -// erosion(4) — which we emit as an 8-char base64url string so the axes aren't -// legible at a glance. It's obfuscation, not encryption: fully reversible, no -// secrets. Erosion occupies bits 44-47, which older (erosion-free) codes left 0, -// so those codes still decode to a pristine board. +// The config packs into 52 bits — seed(32) · edges(4) · springs(3) · size(5) · +// erosion(4) · reach(4) — which we emit as a 9-char base64url string so the +// axes aren't legible at a glance. It's obfuscation, not encryption: fully +// reversible, no secrets. Erosion occupies bits 44-47 and reach bits 48-51, +// both added after the original 8-char (48-bit) format; older codes are a +// char shorter and no longer decode (decodeConfig rejects them), so a stale +// shared link just rolls a fresh board rather than misbehaving. const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; -const CODE_LEN = 8; +const CODE_LEN = 9; -/** A compact, reversible code for a config, e.g. "BQAr3kf9". */ +/** A compact, reversible code for a config, e.g. "BQAr3kf9x". */ export function encodeConfig(c: Config): string { const n = normalizeConfig(c); let v = @@ -229,7 +328,8 @@ export function encodeConfig(c: Config): string { (BigInt(edgesToMask(n.edges)) << 32n) | (BigInt(n.springCount) << 36n) | (BigInt(n.size) << 39n) | - (BigInt(n.erosion) << 44n); + (BigInt(n.erosion) << 44n) | + (BigInt(n.reach) << 48n); let out = ''; for (let i = 0; i < CODE_LEN; i++) { out = B64[Number(v & 63n)] + out; @@ -253,6 +353,7 @@ export function decodeConfig(code: string): Config | null { springCount: Number((v >> 36n) & 0x7n), edges: maskToEdges(Number((v >> 32n) & 0xfn)), erosion: Number((v >> 44n) & 0xfn), + reach: Number((v >> 48n) & 0xfn), terrainSeed: Number(v & 0xffffffffn) >>> 0, }); } @@ -289,16 +390,28 @@ function rollEdges(rng: Rng): Edges { } /** - * Sample the indices of `k` distinct candidates that are pairwise at least - * `minGap` apart (Chebyshev distance). Returns fewer than `k` only if the spacing - * can't be met. Indices (not cells) so callers can look up precomputed per-cell - * data — notably each candidate's outflow signature. + * Sample the indices of `k` distinct candidates — drawn from `pools` (each an + * array of indices into `candidates`, e.g. a reach-narrowed subset) — that are + * pairwise at least `minGap` apart (Chebyshev distance). Pools are tried in + * order: every candidate in `pools[0]` is exhausted (shuffled, so still + * random within the tier) before any candidate from `pools[1]` is considered, + * so a lower-priority pool (e.g. shore cells that must be avoided if + * possible) is only reached into for the picks the higher-priority pool + * genuinely can't fill. Returns fewer than `k` only if the spacing can't be + * met even across every pool. Returns indices into `candidates` (not `pools`) + * so callers can look up precomputed per-cell data — notably each candidate's + * outflow signature — which must stay indexed over the *full* candidate set + * for the uniqueness check to be meaningful. */ -function pickSpringIdx(candidates: Cell[], k: number, rng: Rng, minGap: number): number[] { - const order = candidates.map((_, i) => i); - for (let i = order.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); - [order[i], order[j]] = [order[j], order[i]]; +function pickSpringIdx(candidates: Cell[], pools: number[][], k: number, rng: Rng, minGap: number): number[] { + const order: number[] = []; + for (const pool of pools) { + const shuffled = [...pool]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + order.push(...shuffled); } const chosen: number[] = [];