clone

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

SEED PERSONALITY (M24): worlds develop temperaments

Dami committed 6c1fa38
StyleField v2 — the under-exploited seam gets its identity draw:
- style.ts TEMPERAMENTS (appended draw — finite streams byte-identical,
  genVersion untouched): balanced (common), treacherous (icy/gravity/
  spur + spooky/barren/winter islands), soaring (jump/sprint/helix +
  tropical/meadow), tangled (braids/spurs/reorients + abstract/grove),
  rolling (slopes/corner-drops + rocky/autumn), serene (hazards damped +
  meadow/forest/grove).
- The endless planner's clause draw now composes climate × temperament
  (world.ts composeClauseBias; path.ts takes one merged record); the
  island biome draw leans with the temperament too — a seed's sky,
  hazards and islands cohere into one character.
- Corpus law test: treacherous seeds run measurably icier/heavier than
  serene ones; ≥4 temperaments emerge over 60 seeds.
- Pin cycle #3 of the round: jump→grav-scan-2, propeller→grav-scan-0,
  spur@1→grav-scan-22, 180 reorient back to grav-scan-33, 3-chain →
  grav-scan-32@0-2 (one seed now pins open fork AND full chain), gust
  bank→wind-scan-9@4 (bank run-gap relaxed ≤4: climate biases spread
  gusts thinner than the M19a adjacency law assumed — an island's ledge
  row is the bank unit). 209 green, check clean, goldens re-pinned.
commit
6c1fa38aedfe3b3ef1a0cfb861598040fcd2aa9c
parent
39a9911f5fa1c5a6d58fb7425b8d2d76c10d420f
author
Dami <dami.etoile@gmail.com>
12 files changed, +200 -66
Mprogress.md11+++---
Msrc/gen/endless/furniture.ts5++-
Msrc/gen/endless/islands.ts8++++-
Msrc/gen/endless/path.ts33+++++++++++-------
Msrc/gen/endless/world.ts27++++++++++++--
Msrc/gen/style.ts56++++++++++++++++++++++++++++++
Mtests/endless_test.ts42+++++++++++++++++++++++
Mtests/golden_endless.json22++++++------
Mtests/physics_test.ts40+++++++++++-----------
Mtests/reorient_test.ts6++--
Mtests/scenario_test.ts10+++---
Mtests/spur_test.ts6++--
M progress.md +6 -5
M src/gen/endless/furniture.ts +4 -1
M src/gen/endless/islands.ts +7 -1
M src/gen/endless/path.ts +20 -13
@@ -329,12 +329,20 @@ export function planSegment(
329329 occ: OccView,330330 registry: PieceRegistry,331331 braidBias = 1,332- /** The zone's BASE mood (gen/climate.ts segmentClimate) — biases the333- * clause draw (M21). "" = no bias (finite-mode callers, tests). */334- mood = "",332+ /** Merged clause-weight bias (M21 climate × M24 temperament) — the333+ * caller (world.ts) composes it; {} = neutral (finite-mode callers,334+ * tests). */335+ clauseBias: Record<string, number> = {},335336 ): { plan: SegmentPlan; next: HeadState } {336- return new SegmentBuilder(rng, index, state, occ, registry, braidBias, mood)337- .build();337+ return new SegmentBuilder(338+ rng,339+ index,340+ state,341+ occ,342+ registry,343+ braidBias,344+ clauseBias,345+ ).build();338346 }339347 340348 class SegmentBuilder {
@@ -366,8 +374,8 @@ class SegmentBuilder {
366374 * rngFamily(seed, "braid-breadth")) — some seeds are tangly, some367375 * clean. Caps how far a braid's alt lane may wander out. */368376 private readonly braidBias: number = 1,369- /** Zone base mood (M21) — clause-bias key. */370- private readonly mood: string = "",377+ /** Merged clause bias (climate × temperament). */378+ private readonly clauseBias: Record<string, number> = {},371379 ) {372380 this.pose = { ...state.pose };373381 this.sinceAnchor = state.sinceAnchor;
@@ -584,12 +592,11 @@ class SegmentBuilder {
584592 // -------------------------------------------------------------------------585593 586594 private tryVoidClause(rng: Rng): boolean {587- // M21: the zone's BASE climate biases the hazard appetite — the same588- // seed-pure draw the sky presents (gen/climate.ts). One weighted589- // draw either way: the rng stream shape is identical, only the590- // outcome leans. Snowdrift zones grow icy patches, goldhour zones591- // soar, the dark moods pull toward gravity/forks/helixes.592- const bias = MOOD_CLAUSE_BIAS[this.mood] ?? {};595+ // M21+M24: climate × temperament lean the hazard appetite — one596+ // weighted draw either way (stream shape identical, outcomes lean).597+ // Snowdrift zones grow icy patches; a treacherous SEED grows them598+ // everywhere.599+ const bias = this.clauseBias;593600 const kind = rng.weighted(594601 ([595602 ["straight", 4],
M src/gen/endless/world.ts +23 -4
@@ -62,6 +62,19 @@ import { buildIsland, type Island } from "./islands.ts";
6262 import { planIslandFurniture } from "./furniture.ts";6363 import { planVoidDecor } from "./voidDecor.ts";6464 import { segmentClimate } from "../climate.ts";65+import { MOOD_CLAUSE_BIAS } from "./path.ts";66+67+/** M21 climate × M24 temperament, one multiplicative record. */68+function composeClauseBias(69+ mood: string,70+ temperament: { clauseBias: Record<string, number> },71+): Record<string, number> {72+ const a = MOOD_CLAUSE_BIAS[mood] ?? {};73+ const b = temperament.clauseBias;74+ const out: Record<string, number> = { ...a };75+ for (const [k, v] of Object.entries(b)) out[k] = (out[k] ?? 1) * v;76+ return out;77+}6578 6679 export interface EndlessSegment {6780 index: number;
@@ -209,7 +222,10 @@ export class EndlessWorld {
209222 new OccupancyTx(base),210223 this.registry,211224 this.braidBias,212- segmentClimate(this.seed, index).mood,225+ composeClauseBias(226+ segmentClimate(this.seed, index).mood,227+ this.style.temperament,228+ ),213229 );214230 return true;215231 } catch (err) {
@@ -267,9 +283,12 @@ export class EndlessWorld {
267283 tx,268284 this.registry,269285 this.braidBias,270- // M21: the zone's base climate biases the clause draw — the271- // same seed-pure draw the sky presents.272- segmentClimate(this.seed, i).mood,286+ // M21+M24: the zone's base climate and the seed's temperament287+ // lean the clause draw together.288+ composeClauseBias(289+ segmentClimate(this.seed, i).mood,290+ this.style.temperament,291+ ),273292 );274293 } catch (err) {275294 if (err instanceof PlannerStuck) continue;
M src/gen/style.ts +56 -0
@@ -6,6 +6,53 @@
66 import type { Rng } from "./rng.ts";77 import type { PieceDef } from "./registry.ts";88 9+/** Seed TEMPERAMENT (M24): a drawn identity that leans the ENDLESS10+ * planner's hazard appetite and biome draw — worlds develop coherent11+ * characters instead of uniform sampling. Finite mode never reads it12+ * (genVersion stays put); the draws are APPENDED to the style stream. */13+export interface Temperament {14+ name: string;15+ /** Endless clause-weight multipliers (path.ts combines with the16+ * climate bias). */17+ clauseBias: Record<string, number>;18+ /** Island biome-weight multipliers (islands.ts). */19+ biomeBias: Record<string, number>;20+}21+22+export const TEMPERAMENTS: readonly (Temperament & { weight: number })[] = [23+ { name: "balanced", weight: 3, clauseBias: {}, biomeBias: {} },24+ {25+ name: "treacherous",26+ weight: 1.5,27+ clauseBias: { icy: 1.8, gravity: 1.5, spurDrop: 1.3 },28+ biomeBias: { spooky: 2, barren: 1.5, winter: 1.4 },29+ },30+ {31+ name: "soaring",32+ weight: 1.5,33+ clauseBias: { jump: 1.8, sprint: 1.4, helixDive: 1.3 },34+ biomeBias: { tropical: 1.6, meadow: 1.3 },35+ },36+ {37+ name: "tangled",38+ weight: 1.2,39+ clauseBias: { braid: 1.8, spurDrop: 1.4, reorient: 1.2 },40+ biomeBias: { abstract: 1.6, grove: 1.4 },41+ },42+ {43+ name: "rolling",44+ weight: 1.2,45+ clauseBias: { slope: 1.5, cornerDrop: 1.5, helixDive: 1.2 },46+ biomeBias: { rocky: 1.5, autumn: 1.4 },47+ },48+ {49+ name: "serene",50+ weight: 1,51+ clauseBias: { icy: 0.6, gravity: 0.6, jump: 0.8, braid: 0.8 },52+ biomeBias: { meadow: 2, forest: 1.5, grove: 1.5 },53+ },54+];55+956 export interface StyleField {1057 theme: string;1158 paletteIndex: 0 | 1 | 2;
@@ -19,6 +66,8 @@ export interface StyleField {
1966 decorDensity: number;2067 /** 0..1 — the feature planner's appetite for wide-channel runs. */2168 wideAffinity: number;69+ /** Seed identity (M24) — endless-only hazard/biome leans. */70+ temperament: Temperament;2271 }2372 2473 interface ThemeSpec {
@@ -124,9 +173,16 @@ export function pickStyle(rng: Rng): StyleField {
124173 const zonePhase = rng.float() * Math.PI * 2;125174 const zoneAmp = 0.25 + rng.float() * 0.35;126175 176+ // M24 temperament — APPENDED draw (finite streams byte-identical; the177+ // endless planner and biome draw read it).178+ const temperament = rng.weighted(179+ TEMPERAMENTS.map((t) => [t, t.weight] as const),180+ );181+127182 return {128183 theme: spec.name,129184 paletteIndex,185+ temperament,130186 supportKind,131187 supportColor,132188 decorDensity: spec.decorDensity,
M tests/endless_test.ts +42 -0
@@ -313,3 +313,45 @@ Deno.test("climate: the planner leans with the zone's base mood", () => {
313313 }`,314314 );315315 });316+317+// ---------------------------------------------------------------------------318+// M24 — seed personality.319+// ---------------------------------------------------------------------------320+321+Deno.test("temperament: seeds develop measurable identities", () => {322+ // Group seeds by their drawn temperament and compare hazard rates:323+ // treacherous seeds must run icier+heavier than serene ones across a324+ // corpus, and every temperament must actually emerge.325+ const byTemp = new Map<string, { icy: number; segs: number }>();326+ for (let n = 0; n < 60; n++) {327+ const seed = `temper-${n}`;328+ const w = new EndlessWorld(seed, catalog);329+ const t = w.style.temperament.name;330+ const e = byTemp.get(t) ?? { icy: 0, segs: 0 };331+ for (let i = 0; i < 6; i++) {332+ e.segs++;333+ for (const m of w.get(i).plan.mods) {334+ if (m.kind === "icy" || m.kind === "gravity") e.icy++;335+ }336+ }337+ byTemp.set(t, e);338+ }339+ assert(byTemp.size >= 4, `only ${byTemp.size} temperaments emerged`);340+ const treacherous = byTemp.get("treacherous");341+ const serene = byTemp.get("serene");342+ if (treacherous && serene && treacherous.segs > 20 && serene.segs > 20) {343+ assert(344+ treacherous.icy / treacherous.segs > serene.icy / serene.segs,345+ `treacherous hazard rate ${346+ (treacherous.icy / treacherous.segs).toFixed(3)347+ } not above serene ${(serene.icy / serene.segs).toFixed(3)}`,348+ );349+ }350+ // The style stream stays appended-only: the temperament draw must not351+ // move any pre-M24 style field (the theme of a known seed is pinned).352+ const w = new EndlessWorld("det-seed", catalog);353+ assert(354+ typeof w.style.temperament.name === "string" &&355+ w.style.theme.length > 0,356+ );357+});
M tests/golden_endless.json +11 -11
M tests/physics_test.ts +20 -20
@@ -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-30): pulsed walls never stall the line", async () => {305+Deno.test("scenario gust bank (wind-scan-9): pulsed walls never stall the line", async () => {306306 module_ = await factory();307- // 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 fan307+ // wind-scan-9 grows a phase-locked gust bank on the island run in308+ // segment 4 (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-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);312+ const w = new EndlessWorld("wind-scan-9", catalog);313+ const goal = w.get(5).startS; // past the bank segment314+ const a = driveWith("wind-scan-9", 7, 300, 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-30", 11, 420, goal, 0, false, undefined, true);321+ const b = driveWith("wind-scan-9", 7, 300, goal, 0, false, undefined, true);322322 assertEquals(a.hash, b.hash, "windy drive must replay bit-identically");323323 });324324
@@ -365,22 +365,22 @@ Deno.test("scenario open fork (grav-scan-32): both lanes cross the boundary clea
365365 }366366 });367367 368-Deno.test("scenario braid chain (grav-scan-15): 3-segment fork rides home on both lanes", async () => {368+Deno.test("scenario braid chain (grav-scan-32): 3-segment fork rides home on both lanes", async () => {369369 module_ = await factory();370- // grav-scan-15: open fork @0, STAY-OPEN middle spanning all of @1,370+ // grav-scan-32: open fork @0, STAY-OPEN middle spanning all of @1,371371 // merge continuation @2 (M23 braid networks). The alt lane crosses TWO372372 // segment boundaries — the longest legal chain. Both lanes must drive373373 // clean through the whole network.374- const w = new EndlessWorld("grav-scan-15", catalog);374+ const w = new EndlessWorld("grav-scan-32", catalog);375375 assert(376376 w.get(0).plan.braids.some((b) => b.open && !b.cont) &&377377 w.get(1).plan.braids.some((b) => b.open && b.cont) &&378378 w.get(2).plan.braids.some((b) => b.cont && !b.open),379- "grav-scan-15 lost its 3-segment chain — re-scan",379+ "grav-scan-32 lost its 3-segment chain — re-scan",380380 );381381 const goal = w.get(3).startS + 6;382382 for (const useAlt of [false, true]) {383- const r = driveWith("grav-scan-15", 6, 360, goal, 0, useAlt);383+ const r = driveWith("grav-scan-32", 6, 360, goal, 0, useAlt);384384 assert(385385 !r.fell && r.reachedS >= goal,386386 `chain lane ${useAlt ? "alt" : "main"} failed: fell=${r.fell} ` +
@@ -405,8 +405,8 @@ Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both ki
405405 // the 7-seed survivability gate above.)406406 for (407407 const { seed, kind } of [408- { seed: "helix-scan-6", kind: "propeller" },409- { seed: "grav-scan-25", kind: "jump" },408+ { seed: "grav-scan-0", kind: "propeller" },409+ { seed: "grav-scan-2", kind: "jump" },410410 ]411411 ) {412412 const w = new EndlessWorld(seed, catalog);
@@ -445,8 +445,8 @@ Deno.test("scenario lip-undershoot rescue: the jump decision saves a slow arriva
445445 // the marble's actual state, not a pre-priced launch.446446 for (447447 const { seed, kind } of [448- { seed: "helix-scan-6", kind: "propeller" },449- { seed: "grav-scan-25", kind: "jump" },448+ { seed: "grav-scan-0", kind: "propeller" },449+ { seed: "grav-scan-2", kind: "jump" },450450 ]451451 ) {452452 const w = new EndlessWorld(seed, catalog);
@@ -737,17 +737,17 @@ Deno.test("scenario reorient: marble corkscrews the twist and rides the wall", a
737737 738738 Deno.test("scenario spur-drop: the branch rider falls through onto its own line", async () => {739739 module_ = await factory();740- // grav-scan-36 segment 1 hosts a takeable spur (pinned by spur_test).740+ // grav-scan-22 segment 1 hosts a takeable spur (pinned by spur_test).741741 // Ride the ALT rails: the marble takes the branch at the split, rolls742742 // out over the end-hole board, and must fall THROUGH the hole — proven743743 // by a mid-shaft transit inside the branch cell's column, a place the744744 // MAIN hook never visits at that height — landing on the receiver745745 // passing directly below (Δ 1.0 track), then keep progressing. The746746 // respawn net (fell) must never fire.747- const w = new EndlessWorld("grav-scan-36", catalog);747+ const w = new EndlessWorld("grav-scan-22", catalog);748748 const seg = w.get(1);749749 const spur = seg.plan.spurs[0];750- assert(spur !== undefined, "grav-scan-36 seg 1 lost its spur");750+ assert(spur !== undefined, "grav-scan-22 seg 1 lost its spur");751751 const host = seg.plan.cells[spur.host];752752 const [wx, wz] = DIR_STEP[spur.dir];753753 const holeX = (host.x + wx) * TRACK_SCALE;
@@ -756,7 +756,7 @@ Deno.test("scenario spur-drop: the branch rider falls through onto its own line"
756756 const recFloor = deckFloor - TRACK_SCALE;757757 let shaftTransit = false;758758 const goal = w.get(2).startS;759- const r = drive("grav-scan-36", 4, 120, goal, 0, true, (pos) => {759+ const r = drive("grav-scan-22", 4, 120, goal, 0, true, (pos) => {760760 if (761761 Math.hypot(pos[0] - holeX, pos[2] - holeZ) < 0.7 &&762762 pos[1] > recFloor + 0.5 && pos[1] < recFloor + 1.6
M tests/reorient_test.ts +3 -3
M tests/scenario_test.ts +5 -5
M tests/spur_test.ts +3 -3