- RAISED VIADUCTS (user: pillars vary the island-track connection): ~30% of island crossings drop the whole deck 0.5/1.0 below the track and carry the channel across on kit pier stacks standing on the deck (assemble's structural greedy — abutments + piers every 2.4u, clearance-deduped; shore-side piers stand taller on lower terraces). A viaduct island is its own flavor: no slope terraces, no diamond/ wide/ledge furniture — the stilts are the feature. Deck cells under the run now get biome cap tiles (visible ground under the track). emitSupportStack gains a base elevation; open-mode support candidates exist ONLY where the planner's deck record sits under the whole window. Closed-loop pipeline byte-identical (genVersion unchanged). - RIM-RUN COHERENCE (user: 'the rule is enforcing the bad angle now', round 2 of the triangle blocks): per-cell wedge eligibility flickers along straight rims (half-course neighbors, protrusion corners), and a rim mixing chamfered and square cells reads as a sawtooth comb even though every piece is well-mated. Slopes now only appear inside contiguous runs of >=3 cells eligible toward the SAME side — long rims read as one authored bevel, noisy rims keep calm cubes; a diagonal only caps a committed run. Verified on rim dumps: flickering '3#3#3' rows became cubes, clean '1111'/'3333' runs survive. - Re-pins: endless goldens regenerated; diamond scenario -> grav-scan-24, 180-reorient -> wide-scan-2 (island claim changes shifted those streams). 154 tests, check clean.
- commit
- 4ea8636d1842a0e14613a28154f58e2ca108c4cb
- parent
- 0f1080aae7dc216188cbd12ac44594608e353762
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 47 | +++++++++++++-- |
| M | src/ | 60 | +++++++++++++++++++- |
| M | src/ | 29 | ++++++++-- |
| M | tests/ | 92 | +++++++++++++++--------------- |
| M | tests/ | 9 | ++- |
| M | tests/ | 16 | +++-- |
@@ -139,6 +139,8 @@ export function assemble(
139139 dir: Dir;140140 cradle: boolean;141141 stretch: number;142+ /** Stack base elevation (raised viaduct piers stand on the deck). */143+ baseAt: number;142144 }[] = [];143145 let stretch = 0;144146 @@ -219,9 +221,33 @@ export function assemble(
219221 220222 // Supports: flat elevated pieces get a stack per channel cell; climbs get221223 // one stack at whichever end is elevated (entry for descents, exit-side222- // handled by the neighboring flat window). Void mode: nothing to stand223- // on — no supports at any height.224- if (!opts.open && baseY > 0 && def.deckDelta === 0) {224+ // handled by the neighboring flat window). Void mode: piers exist ONLY225+ // where the planner recorded a DECK below the whole window (raised226+ // viaduct crossings, USER round) — the stacks stand on the island top;227+ // everywhere else the void line floats.228+ const deckBelow = opts.open && opts.deckAt !== undefined229+ ? (() => {230+ const cells = windowCells(def);231+ let drop: number | undefined;232+ for (let j = 0; j < cells.length; j++) {233+ const idx = win.start + j;234+ const c = skeleton.cells[idx % skeleton.cells.length];235+ const top = opts.deckAt!(key(c.x, c.z));236+ if (top === undefined) return undefined;237+ const d = elevation.baseY[idx] - top;238+ if (d < 0.499) return undefined;239+ if (drop === undefined) drop = d;240+ else if (Math.abs(drop - d) > 1e-6) return undefined;241+ }242+ return drop;243+ })()244+ : undefined;245+ if (246+ def.deckDelta === 0 &&247+ (opts.open ? deckBelow !== undefined : baseY > 0)248+ ) {249+ const stackH = opts.open ? deckBelow! : baseY;250+ const stackBase = opts.open ? baseY - deckBelow! : 0;225251 const cells = windowCells(def);226252 for (let j = 0; j < cells.length; j++) {227253 const c = skeleton.cells[(win.start + j) % skeleton.cells.length];@@ -230,10 +256,11 @@ export function assemble(
230256 supportCandidates.push({231257 x: c.x,232258 z: c.z,233- height: baseY,259+ height: stackH,234260 dir: c.dirIn,235261 cradle: true,236262 stretch,263+ baseAt: stackBase,237264 });238265 } else {239266 // Turn cells and off-center channels (waves, bends, wide): a@@ -245,10 +272,11 @@ export function assemble(
245272 supportCandidates.push({246273 x: p[0],247274 z: p[2],248- height: baseY,275+ height: stackH,249276 dir: c.dirIn,250277 cradle: false,251278 stretch,279+ baseAt: stackBase,252280 });253281 }254282 }@@ -270,10 +298,11 @@ export function assemble(
270298 supportCandidates.push({271299 x: p[0],272300 z: p[2],273- height: baseY,301+ height: stackH,274302 dir: c.dirIn,275303 cradle: false,276304 stretch: stretch + 0.5,305+ baseAt: stackBase,277306 });278307 }279308 }@@ -334,6 +363,7 @@ export function assemble(
334363 cand.height,335364 cand.dir,336365 cand.cradle,366+ cand.baseAt,337367 );338368 }339369 }@@ -375,6 +405,9 @@ export function emitSupportStack(
375405 height: number,376406 dir: Dir,377407 cradle: boolean,408+ /** Stack base elevation (default ground; viaduct piers stand on a409+ * deck — every piece lifts by this). */410+ baseAt = 0,378411 ) {379412 // Stack grid: 0.5 per segment; top piece is 0.7 tall with its cradle at380413 // 0.5, so it always sits at height − 0.5. Plain columns (no cradle) end@@ -396,7 +429,7 @@ export function emitSupportStack(
396429 for (const p of pieces) {397430 placements.push({398431 pieceId: supportId(p.seg),399- pos: [x, p.y, z],432+ pos: [x, p.y + baseAt, z],400433 rotation,401434 role: "support",402435 });@@ -364,6 +364,52 @@ export function buildIsland(
364364 return openDir >= 0 ? openDir : null;365365 };366366 367+ // PASS 1.5 — RIM-RUN COHERENCE (user round 2 of the triangle blocks):368+ // per-cell wedge eligibility flickers along a straight rim (half-step369+ // neighbors, protrusion corners, depth dips), and a rim that mixes370+ // chamfered and square cells reads as a sawtooth comb — the wedge law371+ // kept every piece well-mated yet ENFORCED the jagged silhouette.372+ // A slope is now only allowed inside a contiguous run of ≥3 cells all373+ // eligible toward the SAME open side: long rims read as one authored374+ // bevel, noisy rims keep calm cubes. Diagonals only cap a committed375+ // run.376+ const slopeEligible = new Map<string, number>();377+ for (const c of cols.values()) {378+ const k = key(c.wx, c.wz);379+ if (k === waterfallCell || c.onRun || c.dist < 1 || c.depth < 1) continue;380+ const open = DIRS.filter(([dx, dz]) => openAt(c, dx, dz));381+ if (open.length !== 1) continue;382+ const flush = DIRS.every(([dx, dz]) => {383+ const d = dropAt(c, dx, dz);384+ return d <= 0.01 || d >= 0.99;385+ });386+ if (!flush) continue;387+ const w = dirOf(open[0][0], open[0][1]);388+ const sideMated = [1, 3].every((t) => {389+ const s = (w + t) % 4;390+ const n = cols.get(key(c.wx + DIRS[s][0], c.wz + DIRS[s][1]));391+ if (!n || Math.abs(n.topWorld - c.topWorld) > 0.01) return true;392+ return wedgeDirOf(n) !== (w + 2) % 4;393+ });394+ if (sideMated) slopeEligible.set(k, w);395+ }396+ const trimKeep = new Set<string>();397+ for (const [k, w] of slopeEligible) {398+ const [x, z] = k.split(",").map(Number);399+ const [px, pz] = w % 2 === 0 ? [0, 1] : [1, 0]; // along the rim400+ let run = 1;401+ for (const s of [1, -1]) {402+ let n = 1;403+ while (slopeEligible.get(key(x + px * n * s, z + pz * n * s)) === w) {404+ run++;405+ n++;406+ }407+ }408+ if (run >= 3) trimKeep.add(k);409+ }410+ const nearTrimRun = (wx: number, wz: number): boolean =>411+ DIRS.some(([dx, dz]) => trimKeep.has(key(wx + dx, wz + dz)));412+367413 // PASS 2 — emit blocks with shaping.368414 for (const c of cols.values()) {369415 const cellRng = rng.child(`blocks/${c.wx}/${c.wz}`);@@ -405,7 +451,11 @@ export function buildIsland(
405451 });406452 const trim = trimIsland && !isFall && !c.onRun && c.dist >= 1 &&407453 c.depth >= 1 && flush && sideMated &&408- (corner || open.length === 1);454+ (corner455+ // A diagonal cut only CAPS a committed bevel run — a lone corner456+ // notch on an untrimmed rim reads as a random bite.457+ ? nearTrimRun(c.wx, c.wz)458+ : open.length === 1 && trimKeep.has(k));409459 410460 // One face per waterfall column — water can't zigzag around the411461 // corner course to course (the old per-course re-pick did).@@ -538,9 +588,13 @@ export function buildIsland(
538588 shaped.add(k);539589 }540590 541- // Caps: biome surface tile on non-track FLAT tops only.591+ // Caps: biome surface tile on non-track FLAT tops. Raised viaduct592+ // runs (crossing.raised) cap their deck too — the track stands on593+ // piers 0.5/1.0 ABOVE it, so the tile is visible and can't z-fight594+ // the channel.542595 if (543- !c.onRun && !shaped.has(k) && biome.capId &&596+ (!c.onRun || (crossing.raised ?? 0) > 0) && !shaped.has(k) &&597+ biome.capId &&544598 cellRng.chance(biome.capChance)545599 ) {546600 placements.push({@@ -108,6 +108,11 @@ export interface IslandCrossing {
108108 profile: number[];109109 /** Blob margin (Chebyshev rings around the run) the mass may fill. */110110 margin: number;111+ /** Raised viaduct (USER: pillars vary the island↔track connection):112+ * the deck sits this far BELOW the track and kit pier stacks carry113+ * the channel across (assemble emits them on the planner's deck114+ * record). Absent = the classic flush terrace. */115+ raised?: number;111116 /** Extra channel cells beside the run (a diamond's branch columns) —112117 * the island builder treats them as track: full mass, no caps, no113118 * decor. */@@ -1026,6 +1031,16 @@ class SegmentBuilder {
10261031 const runLen = rng.int(5, 11);10271032 const margin = runLen >= 8 ? rng.int(2, 4) : rng.int(1, 3);10281033 1034+ // RAISED VIADUCT crossing (USER: pillars vary the island↔track1035+ // connection): the deck sits 0.5/1.0 BELOW the track and kit pier1036+ // stacks carry the channel across (assemble's structural rule —1037+ // abutments + piers, standing on the deck). A viaduct island is its1038+ // own flavor: no slope terraces, no diamond/wide/ledge furniture —1039+ // the stilts are the feature.1040+ const raised = rng.child("raised").chance(0.3)1041+ ? (rng.child("raised/delta").chance(0.5) ? 0.5 : 1)1042+ : 0;1043+10291044 // The run itself: mostly straight, an occasional bend so the mass10301045 // isn't always a bar. First/last cells stay straight (clean shoreline10311046 // ports). Mass needs at least one block below every run cell.@@ -1041,7 +1056,7 @@ class SegmentBuilder {
10411056 climb: boolean;10421057 }10431058 let slope: RunSlope | null = null;1044- if (runLen >= 7 && rng.child("slope").chance(0.55)) {1059+ if (raised === 0 && runLen >= 7 && rng.child("slope").chance(0.55)) {10451060 const sRng = rng.child("slope/pick");10461061 const climb = sRng.chance(0.45);10471062 const delta = runLen >= 8 && sRng.chance(0.4) ? "1" : "0.5";@@ -1168,7 +1183,10 @@ class SegmentBuilder {
11681183 partLow.set(slope.at - 1, partLow.get(slope.at)!);11691184 }11701185 }1171- const profile = baseYs.map((y, j) => partLow.get(j) ?? y);1186+ // Raised viaduct: the whole surface (run + envelope terraces) sits1187+ // `raised` below the walk — the run cells' [-1, clearance] bands1188+ // already cover the pillar gap.1189+ const profile = baseYs.map((y, j) => (partLow.get(j) ?? y) - raised);11721190 11731191 // Split–merge diamond on the island run (M11 branch respawns): find11741192 // a straight stretch of ≥5 cells, check + claim both flank columns at@@ -1198,7 +1216,7 @@ class SegmentBuilder {
11981216 this.reservations.some((r) => r.start < s0 + len && s0 < r.start + r.len);11991217 12001218 const branchCells: (readonly [number, number])[] = [];1201- if (rng.child("diamond").chance(0.5)) {1219+ if (raised === 0 && rng.child("diamond").chance(0.5)) {12021220 const dRng = rng.child("diamond/pick");12031221 placing:12041222 for (const [s0, l] of dRng.shuffle(stretches)) {@@ -1278,7 +1296,7 @@ class SegmentBuilder {
12781296 // profile bulges 0.35 into them); they're claimed as channel band and12791297 // become track patch (full mass, no caps/decor) like branch columns.12801298 const wideFlanks: (readonly [number, number])[] = [];1281- if (rng.child("wide").chance(0.4)) {1299+ if (raised === 0 && rng.child("wide").chance(0.4)) {12821300 const wRng = rng.child("wide/pick");12831301 placingWide:12841302 for (const [s0, l] of wRng.shuffle(stretches)) {@@ -1360,7 +1378,7 @@ class SegmentBuilder {
13601378 host: number;13611379 side: "L" | "R";13621380 }[] = [];1363- if (rng.child("furniture").chance(0.55)) {1381+ if (raised === 0 && rng.child("furniture").chance(0.55)) {13641382 const fRng = rng.child("furniture/pick");13651383 const candidates: typeof sideCells = [];13661384 for (let j = 1; j < runLen - 1; j++) {@@ -1510,6 +1528,7 @@ class SegmentBuilder {
15101528 topY,15111529 profile,15121530 margin,1531+ ...(raised > 0 ? { raised } : {}),15131532 ...(branchCells.length > 0 ? { branchCells } : {}),15141533 ...(wideFlanks.length > 0 ? { wideFlanks } : {}),15151534 ...(sideCells.length > 0 ? { sideCells } : {}),@@ -1,59 +1,59 @@
11 {22 "__version": "g535114c0fc9e",33 "alpha": [4- "52272b9e11d63b0d",5- "41b8e0bb30161b61",6- "8d6e8787aedd31f7",7- "5539b36b00ef8fe6",8- "478120c5bece97f8",9- "c4f276bfb88bcafc",10- "7570b3f1a7694801",11- "82e226841c07ecc9",12- "c0039a293491de03",13- "52022f6fe5ad9b72",14- "d49af4a43fc95c0f",15- "fe629be3ce28e969"4+ "624e41d3936274a8",5+ "811ea3d4bda98667",6+ "ea77c47118d28b42",7+ "d94f462bad6169b1",8+ "8a5681f26285485f",9+ "d227d6afb24275c6",10+ "7c2359eae09a7dc6",11+ "2158128e73290f0b",12+ "3025b32f15b7d06e",13+ "c251665c4601c051",14+ "9e5a496cf864ad97",15+ "f1f4eb9babe39e36"1616 ],1717 "zx9k2m4p1q": [1818 "74fb045a2dda1057",19- "4eb8b28c68379765",20- "6010ad69646d8ed9",21- "6169c35d49f509b3",22- "5290df1210145737",23- "347a60354030e565",24- "868555782745f932",25- "550c15fb4d8040ba",26- "336ed3eaf267a0c4",27- "e61535a7055d7907",28- "b252538c97a9da30",29- "138344911f0f0517"19+ "e47434996716052f",20+ "efff74cdc4ffbd04",21+ "ae0934b338b712a5",22+ "6a52cab450baccff",23+ "fb5f81f3d1f450e5",24+ "b7bec01a14d579e8",25+ "695aed379287df42",26+ "d585a9acc5d036be",27+ "de63deae183abb94",28+ "ed134d99bcf53fcd",29+ "bc4299a406ff68c7"3030 ],3131 "dami": [32- "9c8776c4a3ed2ffe",33- "ab9be2e024b6eff0",34- "5fe8e5ec24136751",35- "e036d1b06b43b845",36- "145a2a615f4fa375",37- "eb3ae2074729e6e4",38- "a4a999cc4a8d2d0c",39- "1806f6531ed7526f",40- "8b7cd798b47683b4",41- "75287a3932b0fa57",42- "e9eb0c2f232a38a1",43- "6afe3104d1270670"32+ "5fc5f6caf05d117c",33+ "8e2062621838d4d9",34+ "e14d57f51bb973e9",35+ "457a48df838e589d",36+ "9a4cb14260d91e34",37+ "57598ae62f5ce25f",38+ "0fcea243578ad3a4",39+ "48a322db4ea22a5f",40+ "d71191a0ec006419",41+ "facf16d6d599644d",42+ "6742f46639517886",43+ "0095fcd024f98954"4444 ],4545 "marbles": [46- "95b5cab70b08812f",47- "ed2bf534893f4e67",48- "78c593ad1269a98d",46+ "7ae3889f9adee345",47+ "3e97c06685c14e84",48+ "68f9ef0b65546599",4949 "8c8fd758c561218e",50- "ef75023d88f58e8d",51- "550e77dd221c4365",52- "9d61d8a5589b9d79",53- "9df7c8cc1a5352fc",54- "494384fba6c4ff64",55- "ce656a1181dff206",56- "e1d4b67f738015fa",57- "4028db0d0152ad0d"50+ "5c99fb405789d378",51+ "6154068f4f35774f",52+ "e8c644d0f7d64cde",53+ "ee73ca6d91f1aec6",54+ "0e9d23d1b67a6077",55+ "cc4b3cdf0a1ea9b3",56+ "052453bc225cd250",57+ "54551f8b095d2c4a"5858 ]5959 }@@ -366,20 +366,21 @@ Deno.test("scenario lip-undershoot rescue: the jump decision saves a slow arriva
366366 367367 Deno.test("scenario diamond-branch: both branches carry the marble through", async () => {368368 module_ = await factory();369- // wide-scan-1 early segments host an island diamond (re-pinned after369+ // grav-scan-24 early segments host an island diamond (re-pinned after370+ // the viaduct clause, and before that370371 // the gravity clause). Drive through segment 2371372 // twice — once on the main (left) branch rail, once on the alt (right)372373 // — the marble must clear the split, branch, and merge on BOTH. This is373374 // the branch-respawn contract: after a fall, the retry may ride the374375 // other branch, so both must be survivable.375- const w = new EndlessWorld("wide-scan-1", catalog);376+ const w = new EndlessWorld("grav-scan-24", catalog);376377 const hasDiamond = [0, 1, 2].some((i) =>377378 w.get(i).plan.reservations.some((r) => r.pieceIds[0]?.startsWith("diamond"))378379 );379- assert(hasDiamond, "wide-scan-1 early segments lost their diamond");380+ assert(hasDiamond, "grav-scan-24 early segments lost their diamond");380381 const goal = w.get(3).startS;381382 for (const useAlt of [false, true]) {382- const r = drive("wide-scan-1", 5, 90, goal, 0, useAlt);383+ const r = drive("grav-scan-24", 5, 90, goal, 0, useAlt);383384 assert(384385 !r.fell && r.reachedS >= goal,385386 `${useAlt ? "alt" : "main"} branch failed: fell=${r.fell} ` +@@ -63,13 +63,15 @@ Deno.test("reorient: pinned seeds twist early and frames stay continuous", () =>
6363 });6464 6565 Deno.test("reorient: 180° roll presents the descent as a world CLIMB", () => {66- // dami seg 2 carries a 180° roll: plan-down maps to world-up past the67- // twist — the ceiling-world signature.68- const w = new EndlessWorld("dami");69- for (let i = 0; i < 3; i++) w.get(i);70- const s = w.get(2);71- const m = s.plan.mods.find((mm) => mm.kind === "reorient");72- assert(m, "dami seg 2 lost its 180° reorient — re-scan");66+ // wide-scan-2 seg 0 carries a 180° roll: plan-down maps to world-up67+ // past the twist — the ceiling-world signature (re-pinned after the68+ // viaduct clause reshuffled island claims).69+ const w = new EndlessWorld("wide-scan-2");70+ const s = w.get(0);71+ const m = s.plan.mods.find((mm) =>72+ mm.kind === "reorient" && Math.abs(Math.abs(mm.angle!) - Math.PI) < 1e-973+ );74+ assert(m, "wide-scan-2 seg 0 lost its 180° reorient — re-scan");7375 assertAlmostEquals(Math.abs(m.angle!), Math.PI, 1e-12);7476 const down = quatRotate(s.frame!.exit.q, [0, -1, 0]);7577 close(down, [0, 1, 0], 1e-9);