clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

WEATHER BECOMES PHYSICAL (M21): the sky is a generation input now

Dami committed 7e734b7
drawZone was always seed-pure — so the generator legally reads the SAME
climate the sky presents. Systems that never felt each other now couple:
weather → surface → pilot brake styles → boost economy.

- src/gen/climate.ts: MOODS + BIOME_MOOD_BIAS + drawZone moved verbatim
  from client atmosphere.ts (draw SHAPE unchanged — skies are part of
  every shared seed); segmentClimate(seed, i) = the BASE zone draw (the
  biome lean stays presentation-only: islands are planned inside the
  zone, so the base draw is the only self-consistent generation input).
  ZONE_SEGMENTS lives here now; atmosphere.ts re-exports for the client.
- path.ts MOOD_CLAUSE_BIAS: snowdrift grows icy patches (x2.5) and damps
  sprints; goldhour soars (jump x1.6, sprint x1.4, icy x0.5); ember
  sprints + corner-drops; abyss pulls gravity x1.8; midnight forks
  (braid x1.5, spur x1.4); violet spirals (helix x1.7, reorient x1.3).
  One weighted draw either way — stream shape identical, outcomes lean.
- furniture.ts: ember storms gust harder and rise (gust x1.4, updraft
  x1.4); snow muffles gusts (x0.7). Threshold multipliers only.
- Corpus law test: snowdrift icy-rate > 1.5x goldhour; goldhour outjumps
  snowdrift — the coupling is measurable, not cosmetic.
- THE pin cycle: the climate bias moved nearly every endless pin.
  Re-scanned + re-pinned: reorients grav-scan-20(+90)/grav-scan-13(180),
  helix jump-scan-14@0, spurs grav-scan-3@0/grav-scan-36@1 (grav-scan-18
  rejected: a 180 twist upstream of its spur made a hostile scenario),
  gust bank wide-scan-30@8, tagged OPEN braid grav-scan-17@1 (boost-alt
  vs icy-main across a segment boundary), bump/vortex corpora widened.
  206 green, check clean, endless goldens re-pinned, genVersion
  unchanged (finite untouched).
commit
7e734b734e74ea8d5796e68ad0c2f71e5c42ac5f
parent
416d6ab4e8c96bec8d9705859ff7da66797301dd
author
Dami <dami.etoile@gmail.com>
15 files changed, +407 -273
Mprogress.md9+-
Msrc/client/atmosphere.ts179++-------------------------
Msrc/client/endless.ts4+-
Asrc/gen/climate.ts198++++++++++++++++++++++++++++++
Msrc/gen/endless/furniture.ts12+-
Msrc/gen/endless/path.ts29++++-
Msrc/gen/endless/world.ts7+
Mtests/braid_test.ts9+-
Mtests/endless_test.ts50++++++++
Mtests/fanfield_test.ts8+-
Mtests/golden_endless.json96+++++++-------
Mtests/physics_test.ts38+++---
Mtests/reorient_test.ts18+-
Mtests/scenario_test.ts13+-
Mtests/spur_test.ts10+-
M progress.md +5 -4
M src/client/atmosphere.ts +12 -167
@@ -14,174 +14,19 @@
1414 */1515 import * as THREE from "three";1616 import type { SceneCtx } from "./scene.ts";17-import { rngFamily } from "../gen/rng.ts";17+import {18+ drawZone,19+ MOODS,20+ type MoodSpec,21+ type WeatherKind,22+ type ZoneAtmosphere,23+} from "../gen/climate.ts";1824 19-export type WeatherKind = "none" | "snow" | "dust" | "embers";20-21-export interface MoodSpec {22- name: string;23- weight: number;24- sky: number;25- fogNear: number;26- fogFar: number;27- sunColor: number;28- sunIntensity: number;29- /** Sun offset direction (normalized against focus radius). */30- sunDir: readonly [number, number, number];31- hemiSky: number;32- hemiGround: number;33- hemiIntensity: number;34- starColor: number;35- starOpacity: number;36- weathers: readonly WeatherKind[];37- weatherColor?: number;38-}39-40-export const MOODS: readonly MoodSpec[] = [41- {42- name: "midnight",43- weight: 3,44- sky: 0x070b16,45- fogNear: 32,46- fogFar: 115,47- sunColor: 0xfff4e0,48- sunIntensity: 2.2,49- sunDir: [0.6, 1.2, 0.4],50- hemiSky: 0xbfd8ff,51- hemiGround: 0x8a6f5a,52- hemiIntensity: 1.1,53- starColor: 0xc7d5ff,54- starOpacity: 0.85,55- weathers: ["none", "dust"],56- },57- {58- name: "ember",59- weight: 2,60- sky: 0x170a08,61- fogNear: 26,62- fogFar: 95,63- sunColor: 0xffab6e,64- sunIntensity: 2.6,65- sunDir: [1.1, 0.9, 0.5],66- hemiSky: 0xffc9a3,67- hemiGround: 0x5a3a30,68- hemiIntensity: 0.9,69- starColor: 0xffd9b0,70- starOpacity: 0.7,71- weathers: ["embers"],72- weatherColor: 0xffa050,73- },74- {75- name: "violet",76- weight: 2,77- sky: 0x0d0a1e,78- fogNear: 30,79- fogFar: 110,80- sunColor: 0xd9c8ff,81- sunIntensity: 2.0,82- sunDir: [0.5, 1.4, 0.7],83- hemiSky: 0x9f8fdf,84- hemiGround: 0x3a3050,85- hemiIntensity: 1.2,86- starColor: 0xe0d5ff,87- starOpacity: 0.95,88- weathers: ["dust", "none"],89- weatherColor: 0xb8a8ff,90- },91- {92- name: "abyss",93- weight: 1.5,94- sky: 0x05100f,95- fogNear: 20,96- fogFar: 72,97- sunColor: 0x9fe8d8,98- sunIntensity: 1.7,99- sunDir: [0.4, 1.0, 0.8],100- hemiSky: 0x7fd8c8,101- hemiGround: 0x1f4038,102- hemiIntensity: 1.0,103- starColor: 0xafeade,104- starOpacity: 0.3,105- weathers: ["none"],106- },107- {108- name: "snowdrift",109- weight: 2,110- sky: 0x0e141f,111- fogNear: 26,112- fogFar: 98,113- sunColor: 0xdfeaff,114- sunIntensity: 2.3,115- sunDir: [0.7, 1.5, 0.3],116- hemiSky: 0xcfe0ff,117- hemiGround: 0x60707f,118- hemiIntensity: 1.3,119- starColor: 0xffffff,120- starOpacity: 0.9,121- weathers: ["snow"],122- weatherColor: 0xf0f6ff,123- },124- {125- name: "goldhour",126- weight: 1.5,127- sky: 0x140f08,128- fogNear: 34,129- fogFar: 125,130- sunColor: 0xffc46a,131- sunIntensity: 2.9,132- sunDir: [2.0, 0.55, 1.2],133- hemiSky: 0xffe0b0,134- hemiGround: 0x504030,135- hemiIntensity: 0.8,136- starColor: 0xffe9c0,137- starOpacity: 0.45,138- weathers: ["none", "dust"],139- weatherColor: 0xd8b890,140- },141-];142-143-/** One drawn zone: mood + jitter + weather pick. */144-export interface ZoneAtmosphere {145- mood: MoodSpec;146- fogNear: number;147- fogFar: number;148- sunIntensity: number;149- weather: WeatherKind;150-}151-152-/** Mood-weight multipliers per island biome (M18a): a themed stretch153- * leans the sky toward its climate WITHOUT ever forcing it — no bias, no154- * change (today's draw exactly). Presentation-only; never touches155- * generation. */156-const BIOME_MOOD_BIAS: Record<string, Record<string, number>> = {157- winter: { snowdrift: 4, abyss: 1.5 },158- spooky: { midnight: 3, abyss: 3 },159- tropical: { goldhour: 3, ember: 1.5 },160- autumn: { ember: 2.5, goldhour: 2.5 },161-};162-163-/** Deterministic zone draw — `atmosphere` family, keyed by zone index.164- * `biomeBias` (the zone's dominant island biome, if any) multiplies mood165- * weights before the draw; the rng stream shape is IDENTICAL either way166- * (same draws in the same order — only the weighted outcome moves). */167-export function drawZone(168- seed: string,169- zone: number,170- biomeBias?: string,171-): ZoneAtmosphere {172- const rng = rngFamily(seed, "atmosphere").child(`zone/${zone}`);173- const bias = biomeBias ? BIOME_MOOD_BIAS[biomeBias] : undefined;174- const mood = rng.weighted(175- MOODS.map((m) => [m, m.weight * (bias?.[m.name] ?? 1)] as const),176- );177- return {178- mood,179- fogNear: mood.fogNear * (0.9 + rng.float() * 0.25),180- fogFar: mood.fogFar * (0.85 + rng.float() * 0.3),181- sunIntensity: mood.sunIntensity * (0.9 + rng.float() * 0.2),182- weather: rng.pick(mood.weathers),183- };184-}25+// The climate tables + zone draw moved to src/gen/climate.ts (M21):26+// weather is a legal generation input now. Re-exported so client call27+// sites keep their import paths.28+export { drawZone, MOODS };29+export type { MoodSpec, WeatherKind, ZoneAtmosphere };18530 18631 // ---------------------------------------------------------------------------18732 // Weather fields — camera-local particle volumes
M src/client/endless.ts +2 -2
A src/gen/climate.ts +198 -0
@@ -0,0 +1,198 @@
1+/**2+ * Climate (M21): the zone atmosphere draw, SHARED between renderer and3+ * generator. drawZone is a pure label-derived function of (seed, zone),4+ * which makes weather a legal generation input — the same climate the5+ * sky presents can bias the planner's hazard appetites (snowdrift zones6+ * grow icy patches, goldhour zones lean into jumps) without any client7+ * state ever touching generation. The biome bias stays a PRESENTATION8+ * lean only (the client passes it; the generator always reads the BASE9+ * draw — islands are planned inside the zone, so the base draw is the10+ * only self-consistent generation input).11+ *12+ * Moved verbatim from src/client/atmosphere.ts — the draw SHAPE (mood13+ * weighted → fogNear → fogFar → sunIntensity → weather pick) must never14+ * change silently: skies are part of every shared seed.15+ */16+import { rngFamily } from "./rng.ts";17+18+export type WeatherKind = "none" | "snow" | "dust" | "embers";19+20+/** A zone is this many segments (~2 minutes of travel). */21+export const ZONE_SEGMENTS = 5;22+23+export interface MoodSpec {24+ name: string;25+ weight: number;26+ sky: number;27+ fogNear: number;28+ fogFar: number;29+ sunColor: number;30+ sunIntensity: number;31+ /** Sun offset direction (normalized against focus radius). */32+ sunDir: readonly [number, number, number];33+ hemiSky: number;34+ hemiGround: number;35+ hemiIntensity: number;36+ starColor: number;37+ starOpacity: number;38+ weathers: readonly WeatherKind[];39+ weatherColor?: number;40+}41+42+export const MOODS: readonly MoodSpec[] = [43+ {44+ name: "midnight",45+ weight: 3,46+ sky: 0x070b16,47+ fogNear: 32,48+ fogFar: 115,49+ sunColor: 0xfff4e0,50+ sunIntensity: 2.2,51+ sunDir: [0.6, 1.2, 0.4],52+ hemiSky: 0xbfd8ff,53+ hemiGround: 0x8a6f5a,54+ hemiIntensity: 1.1,55+ starColor: 0xc7d5ff,56+ starOpacity: 0.85,57+ weathers: ["none", "dust"],58+ },59+ {60+ name: "ember",61+ weight: 2,62+ sky: 0x170a08,63+ fogNear: 26,64+ fogFar: 95,65+ sunColor: 0xffab6e,66+ sunIntensity: 2.6,67+ sunDir: [1.1, 0.9, 0.5],68+ hemiSky: 0xffc9a3,69+ hemiGround: 0x5a3a30,70+ hemiIntensity: 0.9,71+ starColor: 0xffd9b0,72+ starOpacity: 0.7,73+ weathers: ["embers"],74+ weatherColor: 0xffa050,75+ },76+ {77+ name: "violet",78+ weight: 2,79+ sky: 0x0d0a1e,80+ fogNear: 30,81+ fogFar: 110,82+ sunColor: 0xd9c8ff,83+ sunIntensity: 2.0,84+ sunDir: [0.5, 1.4, 0.7],85+ hemiSky: 0x9f8fdf,86+ hemiGround: 0x3a3050,87+ hemiIntensity: 1.2,88+ starColor: 0xe0d5ff,89+ starOpacity: 0.95,90+ weathers: ["dust", "none"],91+ weatherColor: 0xb8a8ff,92+ },93+ {94+ name: "abyss",95+ weight: 1.5,96+ sky: 0x05100f,97+ fogNear: 20,98+ fogFar: 72,99+ sunColor: 0x9fe8d8,100+ sunIntensity: 1.7,101+ sunDir: [0.4, 1.0, 0.8],102+ hemiSky: 0x7fd8c8,103+ hemiGround: 0x1f4038,104+ hemiIntensity: 1.0,105+ starColor: 0xafeade,106+ starOpacity: 0.3,107+ weathers: ["none"],108+ },109+ {110+ name: "snowdrift",111+ weight: 2,112+ sky: 0x0e141f,113+ fogNear: 26,114+ fogFar: 98,115+ sunColor: 0xdfeaff,116+ sunIntensity: 2.3,117+ sunDir: [0.7, 1.5, 0.3],118+ hemiSky: 0xcfe0ff,119+ hemiGround: 0x60707f,120+ hemiIntensity: 1.3,121+ starColor: 0xffffff,122+ starOpacity: 0.9,123+ weathers: ["snow"],124+ weatherColor: 0xf0f6ff,125+ },126+ {127+ name: "goldhour",128+ weight: 1.5,129+ sky: 0x140f08,130+ fogNear: 34,131+ fogFar: 125,132+ sunColor: 0xffc46a,133+ sunIntensity: 2.9,134+ sunDir: [2.0, 0.55, 1.2],135+ hemiSky: 0xffe0b0,136+ hemiGround: 0x504030,137+ hemiIntensity: 0.8,138+ starColor: 0xffe9c0,139+ starOpacity: 0.45,140+ weathers: ["none", "dust"],141+ weatherColor: 0xd8b890,142+ },143+];144+145+/** Mood-weight multipliers per island biome (M18a): a themed stretch146+ * leans the sky toward its climate WITHOUT ever forcing it — no bias, no147+ * change (today's draw exactly). Presentation-only; never touches148+ * generation. */149+export const BIOME_MOOD_BIAS: Record<string, Record<string, number>> = {150+ winter: { snowdrift: 4, abyss: 1.5 },151+ spooky: { midnight: 3, abyss: 3 },152+ tropical: { goldhour: 3, ember: 1.5 },153+ autumn: { ember: 2.5, goldhour: 2.5 },154+};155+156+/** One drawn zone: mood + jitter + weather pick. */157+export interface ZoneAtmosphere {158+ mood: MoodSpec;159+ fogNear: number;160+ fogFar: number;161+ sunIntensity: number;162+ weather: WeatherKind;163+}164+165+/** Deterministic zone draw — `atmosphere` family, keyed by zone index.166+ * `biomeBias` (the zone's dominant island biome, if any) multiplies mood167+ * weights before the draw; the rng stream shape is IDENTICAL either way168+ * (same draws in the same order — only the weighted outcome moves). */169+export function drawZone(170+ seed: string,171+ zone: number,172+ biomeBias?: string,173+): ZoneAtmosphere {174+ const rng = rngFamily(seed, "atmosphere").child(`zone/${zone}`);175+ const bias = biomeBias ? BIOME_MOOD_BIAS[biomeBias] : undefined;176+ const mood = rng.weighted(177+ MOODS.map((m) => [m, m.weight * (bias?.[m.name] ?? 1)] as const),178+ );179+ return {180+ mood,181+ fogNear: mood.fogNear * (0.9 + rng.float() * 0.25),182+ fogFar: mood.fogFar * (0.85 + rng.float() * 0.3),183+ sunIntensity: mood.sunIntensity * (0.9 + rng.float() * 0.2),184+ weather: rng.pick(mood.weathers),185+ };186+}187+188+/** The BASE climate the generator reads for a segment (M21) — always189+ * unbiased: the biome lean is presentation, and islands are planned190+ * inside the zone, so the base draw is the only self-consistent191+ * generation input. */192+export function segmentClimate(193+ seed: string,194+ segIndex: number,195+): { mood: string; weather: WeatherKind } {196+ const za = drawZone(seed, Math.floor(segIndex / ZONE_SEGMENTS));197+ return { mood: za.mood.name, weather: za.weather };198+}
M src/gen/endless/furniture.ts +9 -3
@@ -155,7 +155,13 @@ export function planIslandFurniture(
155155 /** Assembled placements in TRACK coords — stub hosts are MUTATED156156 * (straight → split) exactly like loop-mode stubs. */157157 assembled: Placement[],158+ /** M21: the zone's BASE weather leans the wind vocabulary — ember159+ * storms gust harder and rise; snow muffles the gusts. Threshold160+ * multipliers only: draw counts never change. */161+ weather: string = "none",158162 ): FurnishedPiece[] {163+ const gustMul = weather === "embers" ? 1.4 : weather === "snow" ? 0.7 : 1;164+ const updraftMul = weather === "embers" ? 1.4 : 1;159165 const out: FurnishedPiece[] = [];160166 const gusts: GustEntry[] = [];161167 const windowAt = (idx: number): RealizedWindow | undefined =>
@@ -281,7 +287,7 @@ export function planIslandFurniture(
281287 },282288 host: sc.host,283289 });284- } else if (variant < BOOST_FAN_CHANCE + UPDRAFT_FAN_CHANCE) {290+ } else if (variant < BOOST_FAN_CHANCE + UPDRAFT_FAN_CHANCE * updraftMul) {285291 // UPDRAFT COLUMN: table fan flat at the channel lip, blowing286292 // straight up — ~9.5 u/s² against gravity 20 (half-weight lift287293 // wobble / jump assist, not orbit) for marbles hugging this
@@ -313,7 +319,7 @@ export function planIslandFurniture(
313319 // variant), so they exist only in this branch and this cell's314320 // child stream; nothing before them moved.315321 const extra = r.float();316- if (extra < GUST_CHANCE) {322+ if (extra < GUST_CHANCE * gustMul) {317323 // GUST WALL: the cross fan keeps its mesh, pose and spin —318324 // the upgrade is a pulsed wind field spanning the channel.319325 // Coordination (banks / opposed pairs) is a phase-only
@@ -340,7 +346,7 @@ export function planIslandFurniture(
340346 host: sc.host,341347 });342348 gusts.push({ wind, host: sc.host, side: sc.side, isle: crossing.id });343- } else if (extra < GUST_CHANCE + VORTEX_CHANCE) {349+ } else if (extra < GUST_CHANCE * gustMul + VORTEX_CHANCE) {344350 // VORTEX: table fan at the lip (ledge rule holds — the345351 // housing rounds to its cell like the updraft); the swirl346352 // column parks over the channel and marbles arc around it.
M src/gen/endless/path.ts +26 -3
@@ -241,6 +241,18 @@ export interface OpenBraidState {
241241 * The continuation must merge before this — bounded irresolution. */242242 export const OPEN_BRAID_CAP = 34;243243 244+/** M21 — climate → hazard appetite (clause-weight multipliers by the245+ * zone's BASE mood). Legibility rule: each mood leans at most 2-3246+ * clauses, hard enough to FEEL across a 5-segment zone. */247+export const MOOD_CLAUSE_BIAS: Record<string, Record<string, number>> = {248+ snowdrift: { icy: 2.5, sprint: 0.7 },249+ goldhour: { jump: 1.6, sprint: 1.4, icy: 0.5 },250+ ember: { sprint: 1.5, cornerDrop: 1.3 },251+ abyss: { gravity: 1.8, icy: 1.3 },252+ midnight: { braid: 1.5, spurDrop: 1.4 },253+ violet: { helixDive: 1.7, reorient: 1.3 },254+};255+244256 export interface SegmentPlan {245257 index: number;246258 cells: OpenCell[];
@@ -312,8 +324,11 @@ export function planSegment(
312324 occ: OccView,313325 registry: PieceRegistry,314326 braidBias = 1,327+ /** The zone's BASE mood (gen/climate.ts segmentClimate) — biases the328+ * clause draw (M21). "" = no bias (finite-mode callers, tests). */329+ mood = "",315330 ): { plan: SegmentPlan; next: HeadState } {316- return new SegmentBuilder(rng, index, state, occ, registry, braidBias)331+ return new SegmentBuilder(rng, index, state, occ, registry, braidBias, mood)317332 .build();318333 }319334
@@ -346,6 +361,8 @@ class SegmentBuilder {
346361 * rngFamily(seed, "braid-breadth")) — some seeds are tangly, some347362 * clean. Caps how far a braid's alt lane may wander out. */348363 private readonly braidBias: number = 1,364+ /** Zone base mood (M21) — clause-bias key. */365+ private readonly mood: string = "",349366 ) {350367 this.pose = { ...state.pose };351368 this.sinceAnchor = state.sinceAnchor;
@@ -562,8 +579,14 @@ class SegmentBuilder {
562579 // -------------------------------------------------------------------------563580 564581 private tryVoidClause(rng: Rng): boolean {582+ // M21: the zone's BASE climate biases the hazard appetite — the same583+ // seed-pure draw the sky presents (gen/climate.ts). One weighted584+ // draw either way: the rng stream shape is identical, only the585+ // outcome leans. Snowdrift zones grow icy patches, goldhour zones586+ // soar, the dark moods pull toward gravity/forks/helixes.587+ const bias = MOOD_CLAUSE_BIAS[this.mood] ?? {};565588 const kind = rng.weighted(566- [589+ ([567590 ["straight", 4],568591 ["turn", 2.5],569592 ["jog", 1.5],
@@ -577,7 +600,7 @@ class SegmentBuilder {
577600 ["spurDrop", 0.35],578601 ["reorient", 0.45],579602 ["braid", 0.5],580- ] as const,603+ ] as const).map(([k, w]) => [k, w * (bias[k] ?? 1)] as const),581604 );582605 const attempts: (() => boolean)[] = [];583606 switch (kind) {
M src/gen/endless/world.ts +7 -0
M tests/braid_test.ts +5 -4
M tests/endless_test.ts +50 -0
@@ -13,6 +13,7 @@ import { TRACK_SCALE } from "../src/gen/endless/scale.ts";
1313 import { segPoint } from "../src/gen/geom.ts";1414 import type { DecorCatalog } from "../src/gen/decor.ts";1515 import { goldenCatalog } from "../tools/pin_endless.ts";16+import { segmentClimate } from "../src/gen/climate.ts";1617 1718 // The catalog the client streams with (all decor packs — M18a).1819 const catalog: DecorCatalog = goldenCatalog();
@@ -263,3 +264,52 @@ Deno.test("themed biomes: every island placement resolves, caps stay 1x1", () =>
263264 );264265 }265266 });267+268+// ---------------------------------------------------------------------------269+// M21 — weather becomes physical.270+// ---------------------------------------------------------------------------271+272+Deno.test("climate: the planner leans with the zone's base mood", () => {273+ // Corpus law: snowdrift zones grow measurably more icy windows than274+ // goldhour zones; goldhour zones jump more. The climate read is the275+ // BASE draw — the same seed-pure function the sky presents.276+ const per = new Map<string, { icy: number; jump: number; segs: number }>();277+ for (let n = 0; n < 40; n++) {278+ const w = new EndlessWorld(`climate-${n}`, catalog);279+ for (let i = 0; i < 8; i++) {280+ const { mood } = segmentClimate(`climate-${n}`, i);281+ const seg = w.get(i);282+ const e = per.get(mood) ?? { icy: 0, jump: 0, segs: 0 };283+ e.segs++;284+ for (const m of seg.plan.mods) {285+ if (m.kind === "icy") e.icy++;286+ if (m.kind === "jump" || m.kind === "propeller") e.jump++;287+ }288+ per.set(mood, e);289+ }290+ }291+ const rate = (m: string, k: "icy" | "jump"): number => {292+ const e = per.get(m);293+ return e && e.segs > 10 ? e[k] / e.segs : NaN;294+ };295+ const icySnow = rate("snowdrift", "icy");296+ const icyGold = rate("goldhour", "icy");297+ const jumpGold = rate("goldhour", "jump");298+ const jumpSnow = rate("snowdrift", "jump");299+ assert(300+ Number.isNaN(icySnow) === false && Number.isNaN(icyGold) === false,301+ "corpus too small for climate comparison",302+ );303+ assert(304+ icySnow > icyGold * 1.5,305+ `snowdrift icy rate ${icySnow.toFixed(3)} not clearly above goldhour ${306+ icyGold.toFixed(3)307+ }`,308+ );309+ assert(310+ jumpGold > jumpSnow,311+ `goldhour jump rate ${jumpGold.toFixed(3)} not above snowdrift ${312+ jumpSnow.toFixed(3)313+ }`,314+ );315+});
M tests/fanfield_test.ts +4 -4
M tests/golden_endless.json +48 -48
@@ -1,59 +1,59 @@
11 {22 "__version": "g555803244402",33 "alpha": [4- "76c97aa76c8a4856",5- "d7667831de561db6",6- "6605054aa23955fe",7- "56a60f71540070a3",8- "421ae921a35230d8",9- "58524ce5390ed8ca",10- "5e2cc6bfef74ca65",11- "3ea7f45732d55eee",12- "a8cc3cb074cdd426",13- "34981e4da464882a",14- "a01cf395a6f5e9ad",15- "9269ceea5ac033f7"4+ "b33807289593bb7a",5+ "b9c9bc4efd694da6",6+ "a0cfa5fe96c76042",7+ "662dc438fef17279",8+ "8fba5072b96cf02e",9+ "16c1265b72a807e6",10+ "06f2553cfd9cf5da",11+ "cc338b1efd405a33",12+ "da107778669a117a",13+ "b40f3a9e6e587c2f",14+ "c74c7d0a9ce54d81",15+ "5534ca0a8206eb93"1616 ],1717 "zx9k2m4p1q": [18- "953000cc72c006de",19- "e4cff0ba37438341",20- "f42849d9719de86a",21- "6c530245a512d370",22- "152008334da2dac6",23- "ca3c912955f256ed",24- "509409b6fd771230",25- "f23d4f4c1f28aa33",26- "3c80215d656b7cbe",27- "36d1abddc25b9ef3",28- "08fadf7911b07e85",29- "a2305d416d01cf22"18+ "6f78daddd1c0bb41",19+ "850d2baff7bc0679",20+ "30d8c6d4c052b87c",21+ "918d96eb2f4807b1",22+ "36b7449fb0ef3ae8",23+ "b64f27d2443840c5",24+ "bb5e76800945f998",25+ "2f80df594a27dc05",26+ "ada2ed037bd4c913",27+ "611797a7dfe497ed",28+ "b3bef67b3e62e6c4",29+ "543c92fe289ef62b"3030 ],3131 "dami": [32- "6f7c2df67230b331",33- "5b341ee6e4d55e9a",34- "6d04ac92e60004d4",35- "0854f062a74a4797",36- "56daa31fbe30f013",37- "2b9204f2a1c38639",38- "53a805d9b41c764c",39- "edaa5a7d93b0ba7d",40- "65376c2cbdca45fe",41- "8b0dd61ebfbb8178",42- "6640de5a3b4c5f78",43- "b87bcda9bf82596f"32+ "f917082c308fd344",33+ "f7bb513e7ced869c",34+ "b0834d6bae248032",35+ "03ab51d0fb014e9f",36+ "3dbde6539329e204",37+ "1521c59fb7168cbc",38+ "b7d1590408562704",39+ "9efbe9f40adfabf5",40+ "0a14fadf3358f6d6",41+ "a4b39685e1900827",42+ "0e07fda1fe7507b2",43+ "1163498944eda36d"4444 ],4545 "marbles": [46- "b3f1ebd2ebe09abd",47- "e79e59f656a205c1",48- "0fb0ab7baa391c4e",49- "a9d82f47ee723d6e",50- "58d7465e7448a844",51- "9d2ede8ea762b69f",52- "aca84c6d86c9f416",53- "e77b8633d9e2015a",54- "1445cf669ab83d34",55- "5fe7a07bab775cdf",56- "0d5e85ca411aa200",57- "df8f0b5182c73dc9"46+ "905f4cc52c9a1638",47+ "7471c7a4b3d2138c",48+ "d1c400296cd2e336",49+ "b030a273ec260473",50+ "124451dad6cd17bd",51+ "593dfada1a50d054",52+ "3ff744035b926210",53+ "6107102bbdbe4166",54+ "8fa49d1e8af744e3",55+ "c45c6d2850dd8bc7",56+ "fe3206c693ec85bf",57+ "9bdad5881b5fa5ce"5858 ]5959 }
M tests/physics_test.ts +19 -19
@@ -302,23 +302,23 @@ Deno.test("physics survivability: assisted marble clears early segments", async
302302 );303303 });304304 305-Deno.test("scenario gust bank (wide-scan-13): pulsed walls never stall the line", async () => {305+Deno.test("scenario gust bank (wide-scan-30): pulsed walls never stall the line", async () => {306306 module_ = await factory();307- // wide-scan-13 grows a phase-locked gust bank on the island run in308- // segment 5 (pinned in scenario_test). Drive THROUGH it with the fan307+ // wide-scan-30 grows a phase-locked gust bank on the island run in308+ // segment 8 (pinned in scenario_test). Drive THROUGH it with the fan309309 // wind pass on: the marble must clear the bank without stalling or310310 // being blown off the channel, and the run must replay bit-identically311311 // (the pulse clock is sim time, not wall time).312- const w = new EndlessWorld("wide-scan-13", catalog);313- const goal = w.get(6).startS; // past the bank segment314- const a = driveWith("wide-scan-13", 8, 300, goal, 0, false, undefined, true);312+ const w = new EndlessWorld("wide-scan-30", catalog);313+ const goal = w.get(9).startS; // past the bank segment314+ const a = driveWith("wide-scan-30", 11, 420, goal, 0, false, undefined, true);315315 assert(316316 !a.fell && a.reachedS >= goal,317317 `gust bank stalled the marble: fell=${a.fell} reached=${318318 a.reachedS.toFixed(1)319319 }/${goal.toFixed(1)}`,320320 );321- const b = driveWith("wide-scan-13", 8, 300, goal, 0, false, undefined, true);321+ const b = driveWith("wide-scan-30", 11, 420, goal, 0, false, undefined, true);322322 assertEquals(a.hash, b.hash, "windy drive must replay bit-identically");323323 });324324
@@ -513,21 +513,21 @@ Deno.test("physics determinism: identical trajectory hash across runs", async ()
513513 514514 Deno.test("scenario helix-dive: marble rides the full spiral and the underpass", async () => {515515 module_ = await factory();516- // grav-scan-6 segment 0 hosts a full-helix ensemble (re-pinned after516+ // jump-scan-14 segment 0 hosts a full-helix ensemble (re-pinned after517517 // the braid clause reshuffled the streams; the reorient clause moved518518 // it before that). The marble must enter the approach corner,519519 // ride the 360° spiral down 2.0 (helix family mod: banked, ridden520520 // fast), come out through its own underpass, and keep rolling.521- const w = new EndlessWorld("grav-scan-6", catalog);521+ const w = new EndlessWorld("jump-scan-14", catalog);522522 const seg = w.get(0);523523 const r0 = seg.plan.reservations.find((r) =>524524 /^helix-(large-)?(left|right)$/.test(r.pieceIds[0])525525 );526- assert(r0 !== undefined, "grav-scan-6 seg 0 lost its full helix");526+ assert(r0 !== undefined, "jump-scan-14 seg 0 lost its full helix");527527 const wi = seg.windowRanges.findIndex((wr) => wr.start === r0.start);528528 let endS = 0;529529 for (let k = 0; k <= wi; k++) endS += seg.path.segments[k].length;530- const r = drive("grav-scan-6", 3, 60, endS + 8);530+ const r = drive("jump-scan-14", 3, 60, endS + 8);531531 assert(!r.fell, "marble fell during the helix dive");532532 assert(533533 r.reachedS >= endS + 7,
@@ -671,23 +671,23 @@ Deno.test("scenario gravity-run: tilted gravity surges the marble on the flat",
671671 672672 Deno.test("scenario reorient: marble corkscrews the twist and rides the wall", async () => {673673 module_ = await factory();674- // jump-scan-25 segment 0 rolls +90° about the travel axis (re-pinned674+ // grav-scan-20 segment 0 rolls +90° about the travel axis (re-pinned675675 // after the braid clause reshuffled the streams; biome-3 held the pin676676 // before that). The marble must enter the twist chute,677677 // corkscrew through it, and keep making progress DOWNSTREAM of the678678 // run — where the channel is a WALL in world space and only the679679 // conjugated control law + rotated gravity delta keep it in the pipe.680- const w = new EndlessWorld("jump-scan-25", catalog);680+ const w = new EndlessWorld("grav-scan-20", catalog);681681 const seg = w.get(0);682682 const f = seg.frame;683- assert(f?.twist, "jump-scan-25 seg 0 lost its reorient — re-scan");683+ assert(f?.twist, "grav-scan-20 seg 0 lost its reorient — re-scan");684684 const tw = f.twist!;685685 // Drive well past the twist (its axial end, plus a margin).686686 let twistEndS = 0;687687 for (let k = 0; k <= tw.to; k++) twistEndS += seg.path.segments[k].length;688688 const goal = twistEndS + 10;689689 let wallProof = false;690- const r = drive("jump-scan-25", 4, 120, goal, 0, false, (pos) => {690+ const r = drive("grav-scan-20", 4, 120, goal, 0, false, (pos) => {691691 // Wall-riding evidence: past the run, the marble's PLAN height sits692692 // near the floor while its world position is genuinely rotated away693693 // from the plan point (the twist really presented).
@@ -713,17 +713,17 @@ Deno.test("scenario reorient: marble corkscrews the twist and rides the wall", a
713713 714714 Deno.test("scenario spur-drop: the branch rider falls through onto its own line", async () => {715715 module_ = await factory();716- // jump-scan-4 segment 1 hosts a takeable spur (pinned by spur_test).716+ // grav-scan-36 segment 1 hosts a takeable spur (pinned by spur_test).717717 // Ride the ALT rails: the marble takes the branch at the split, rolls718718 // out over the end-hole board, and must fall THROUGH the hole — proven719719 // by a mid-shaft transit inside the branch cell's column, a place the720720 // MAIN hook never visits at that height — landing on the receiver721721 // passing directly below (Δ 1.0 track), then keep progressing. The722722 // respawn net (fell) must never fire.723- const w = new EndlessWorld("jump-scan-4", catalog);723+ const w = new EndlessWorld("grav-scan-36", catalog);724724 const seg = w.get(1);725725 const spur = seg.plan.spurs[0];726- assert(spur !== undefined, "jump-scan-4 seg 1 lost its spur");726+ assert(spur !== undefined, "grav-scan-36 seg 1 lost its spur");727727 const host = seg.plan.cells[spur.host];728728 const [wx, wz] = DIR_STEP[spur.dir];729729 const holeX = (host.x + wx) * TRACK_SCALE;
@@ -732,7 +732,7 @@ Deno.test("scenario spur-drop: the branch rider falls through onto its own line"
732732 const recFloor = deckFloor - TRACK_SCALE;733733 let shaftTransit = false;734734 const goal = w.get(2).startS;735- const r = drive("jump-scan-4", 4, 120, goal, 0, true, (pos) => {735+ const r = drive("grav-scan-36", 4, 120, goal, 0, true, (pos) => {736736 if (737737 Math.hypot(pos[0] - holeX, pos[2] - holeZ) < 0.7 &&738738 pos[1] > recFloor + 0.5 && pos[1] < recFloor + 1.6
M tests/reorient_test.ts +9 -9
@@ -19,12 +19,12 @@ const close = (a: readonly number[], b: readonly number[], eps = 1e-9) => {
1919 };2020 2121 Deno.test("reorient: pinned seeds twist early and frames stay continuous", () => {22- // jump-scan-25 rolls +90° in segment 0 (re-pinned after the braid23- // clause reshuffled the streams; before that biome-3 held the pin).24- const w = new EndlessWorld("jump-scan-25");22+ // grav-scan-20 rolls +90° in segment 0 (re-pinned after the M2123+ // climate-bias reshuffle; jump-scan-25 and biome-3 held it before).24+ const w = new EndlessWorld("grav-scan-20");2525 const s0 = w.get(0);2626 const m = s0.plan.mods.find((mm) => mm.kind === "reorient");27- assert(m, "jump-scan-25 seg 0 lost its reorient — re-scan");27+ assert(m, "grav-scan-20 seg 0 lost its reorient — re-scan");2828 assertAlmostEquals(m.angle!, Math.PI / 2, 1e-12);2929 assert(s0.frame?.twist, "reorient must stamp a twist frame");3030
@@ -62,22 +62,22 @@ Deno.test("reorient: pinned seeds twist early and frames stay continuous", () =>
6262 });6363 6464 Deno.test("reorient: 180° roll presents the descent as a world CLIMB", () => {65- // grav-scan-33 seg 0 carries a 180° roll: plan-down maps to world-up65+ // grav-scan-13 seg 0 carries a 180° roll: plan-down maps to world-up6666 // past the twist — the ceiling-world signature (re-pinned after the6767 // braid clause reshuffled the streams; wide-scan-2 before that).68- const w = new EndlessWorld("grav-scan-33");68+ const w = new EndlessWorld("grav-scan-13");6969 const s = w.get(0);7070 const m = s.plan.mods.find((mm) =>7171 mm.kind === "reorient" && Math.abs(Math.abs(mm.angle!) - Math.PI) < 1e-97272 );73- assert(m, "grav-scan-33 seg 0 lost its 180° reorient — re-scan");73+ assert(m, "grav-scan-13 seg 0 lost its 180° reorient — re-scan");7474 assertAlmostEquals(Math.abs(m.angle!), Math.PI, 1e-12);7575 const down = quatRotate(s.frame!.exit.q, [0, -1, 0]);7676 close(down, [0, 1, 0], 1e-9);7777 });7878 7979 Deno.test("reorient: frame law is exact at the run's ends", () => {80- const w = new EndlessWorld("jump-scan-25");80+ const w = new EndlessWorld("grav-scan-20");8181 const f = w.get(0).frame!;8282 const tw = f.twist!;8383 // At the pivot the frames agree; past the run the exit frame owns it.
@@ -95,6 +95,6 @@ Deno.test("reorient: frame law is exact at the run's ends", () => {
9595 affineApply(f.exit, past),9696 );9797 // Determinism: a second world reproduces the identical frame.98- const w2 = new EndlessWorld("jump-scan-25");98+ const w2 = new EndlessWorld("grav-scan-20");9999 assertEquals(w2.get(0).frame, f);100100 });
M tests/scenario_test.ts +8 -5
@@ -335,6 +335,9 @@ Deno.test("bump lips: bumps realize ONLY fully inside a boost zone", () => {
335335 "det-seed",336336 "atmo-seed",337337 ...Array.from({ length: 15 }, (_, i) => `jump-scan-${i}`),338+ // M21 re-scan: sprint zones migrated with the climate bias — the339+ // grav family carries the bump exemplars now.340+ ...Array.from({ length: 10 }, (_, i) => `grav-scan-${i}`),338341 ];339342 let bumps = 0;340343 for (const seed of seeds) {
@@ -1088,18 +1091,18 @@ Deno.test("island ground vocabulary: wide runs, furniture, solids obey the rules
10881091 // ---------------------------------------------------------------------------10891092 10901093 Deno.test("gust walls: pulses in band, banks phase-locked, pairs anti-phase", () => {1091- // Pinned exemplar: wide-scan-13 grows a phase-locked bank on adjacent1092- // ledges in segment 5 (found by scan; re-scan if furniture draws move).1093- const pinned = new EndlessWorld("wide-scan-13", catalog);1094+ // Pinned exemplar: wide-scan-30 grows a phase-locked bank in segment 81095+ // (M21 re-scan; wide-scan-13 held the pin before the climate bias).1096+ const pinned = new EndlessWorld("wide-scan-30", catalog);10941097 let pinnedBank = 0;1095- for (let i = 0; i <= 5; i++) {1098+ for (let i = 0; i <= 8; i++) {10961099 const gusts = pinned.get(i).placements.filter((p) => p.wind?.pulse);10971100 if (gusts.length >= 2) {10981101 const periods = gusts.map((g) => g.wind!.pulse!.period);10991102 if (periods.every((p) => p === periods[0])) pinnedBank++;11001103 }11011104 }1102- assert(pinnedBank > 0, "wide-scan-13 lost its phase-locked gust bank");1105+ assert(pinnedBank > 0, "wide-scan-30 lost its phase-locked gust bank");11031106 11041107 // Corpus-wide laws over the standard scan families.11051108 const seeds = [
M tests/spur_test.ts +5 -5