- commit
- 825e37fd4ed080cbf026239bfa895cb14ede3ddf
- parent
- 798eec3aa8be1e0108882bddbcf0f73dc478965f
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 11 | +++ |
| A | src/ | 126 | ++++++++++++++++++++++++++++++ |
| M | tests/ | 2 | +- |
| M | tests/ | 49 | ++++++++++++ |
| M | tools/ | 8 | -- |
@@ -17,6 +17,7 @@ import { assemble } from "./assemble.ts";
1717 import { planStubs } from "./stubs.ts";1818 import { planTakeableSpurs } from "./takeable.ts";1919 import { planFans } from "./fans.ts";20+import { planSceneryRing } from "./scenery.ts";2021 import {2122 cellSetOracle,2223 type DecorCatalog,@@ -143,6 +144,16 @@ export function generateTrack(
143144 );144145 placements.push(...fans.placements);145146 for (const cell of fans.cells) assembled.trackCells.add(cell);147+ // Curve-wide scenery ring (M13): the off-grid family as a freestanding148+ // self-closed roundabout beside the line. Its cells join trackCells so149+ // the decor field bands around it (a clearing with a landmark).150+ const scenery = planSceneryRing(151+ rngFamily(seed, "scenery"),152+ assembled.trackCells,153+ );154+ placements.push(...scenery.placements);155+ for (const cell of scenery.cells) assembled.trackCells.add(cell);156+146157 const setting = pickSetting(rngFamily(seed, "setting"), catalog);147158 if (!opts.skipDecor) {148159 // Decor is a world field: single-track mode realizes it for the track's@@ -0,0 +1,126 @@
1+/**2+ * Curve-wide scenery macro (M13) — the off-grid curve-wide family enters3+ * as a SET-PIECE: four quarter-turn wide arcs close into a freestanding4+ * ring (a roundabout display) on open ground beside the track. The family5+ * cannot mate with the 1-unit port lattice (ports on ±0.85-style bbox6+ * faces, lane non-concentric) — but it mates perfectly with ITSELF, so7+ * the ring is self-closed: every port flush, nothing juts (composition8+ * honesty). Solid variants make a filled plaza; the small size has no9+ * solid form and always builds open.10+ *11+ * Measured (tools/measure_ports.ts): each variant's ports sit centered on12+ * its x− and z− faces; rotations [0, 3, 2, 1] at ring-center offsets13+ * (+h,+h), (−h,+h), (−h,−h), (+h,−h) mate all four in a closed circuit.14+ */15+import type { Placement } from "../shared/types.ts";16+import type { Rng } from "./rng.ts";17+18+const RING_CHANCE = 0.3;19+20+interface Variant {21+ id: string;22+ solidId?: string;23+ /** Piece bbox half-extent (ring pieces sit ±h from the ring center). */24+ h: number;25+}26+27+const VARIANTS: readonly Variant[] = [28+ { id: "curve-wide", h: 0.85 },29+ { id: "curve-wide-medium", solidId: "curve-wide-solid-medium", h: 1.35 },30+ { id: "curve-wide-large", solidId: "curve-wide-solid-large", h: 1.85 },31+];32+33+/** Piece center offsets (units of h) and rotations that close the ring. */34+const CORNERS: readonly (readonly [number, number, number])[] = [35+ [1, 1, 0],36+ [-1, 1, 3],37+ [-1, -1, 2],38+ [1, -1, 1],39+];40+41+export interface SceneryPlan {42+ placements: Placement[];43+ cells: string[];44+}45+46+export function planSceneryRing(47+ rng: Rng,48+ trackCells: ReadonlySet<string>,49+): SceneryPlan {50+ const empty: SceneryPlan = { placements: [], cells: [] };51+ if (!rng.chance(RING_CHANCE)) return empty;52+53+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;54+ for (const cell of trackCells) {55+ const [x, z] = cell.split(",").map(Number);56+ minX = Math.min(minX, x);57+ maxX = Math.max(maxX, x);58+ minZ = Math.min(minZ, z);59+ maxZ = Math.max(maxZ, z);60+ }61+ if (!Number.isFinite(minX)) return empty;62+63+ // Pick the variant first (bigger rings preferred when they fit), then64+ // search ring centers on cell corners around the track: the whole65+ // footprint block must be clear of track, and the ring must stand near66+ // the line (visible, not fog-lost) without touching it.67+ const order = rng.shuffle(68+ VARIANTS.flatMap((v) => (v.h > 1 ? [v, v] : [v])),69+ );70+ for (const variant of order) {71+ const reach = variant.h * 2; // footprint half-extent around the center72+ const cellsFor = (rx: number, rz: number): string[] | null => {73+ const out: string[] = [];74+ let nearTrack = false;75+ for (76+ let cx = Math.floor(rx - reach);77+ cx <= Math.ceil(rx + reach);78+ cx++79+ ) {80+ for (81+ let cz = Math.floor(rz - reach);82+ cz <= Math.ceil(rz + reach);83+ cz++84+ ) {85+ if (trackCells.has(`${cx},${cz}`)) return null;86+ out.push(`${cx},${cz}`);87+ }88+ }89+ // Proximity: some track cell within reach+4 of the center.90+ outer:91+ for (let dx = -Math.ceil(reach + 4); dx <= reach + 4; dx++) {92+ for (let dz = -Math.ceil(reach + 4); dz <= reach + 4; dz++) {93+ if (trackCells.has(`${Math.round(rx + dx)},${Math.round(rz + dz)}`)) {94+ nearTrack = true;95+ break outer;96+ }97+ }98+ }99+ return nearTrack ? out : null;100+ };101+102+ const candidates: [number, number][] = [];103+ for (let x = minX - 8; x <= maxX + 8; x++) {104+ for (let z = minZ - 8; z <= maxZ + 8; z++) {105+ if (cellsFor(x + 0.5, z + 0.5) !== null) candidates.push([x, z]);106+ }107+ }108+ if (candidates.length === 0) continue;109+110+ const [gx, gz] = rng.pick(111+ candidates.sort((a, b) => a[0] - b[0] || a[1] - b[1]),112+ );113+ const rx = gx + 0.5;114+ const rz = gz + 0.5;115+ const solid = variant.solidId !== undefined && rng.chance(0.5);116+ const id = solid ? variant.solidId! : variant.id;117+ const placements: Placement[] = CORNERS.map(([sx, sz, r]) => ({118+ pieceId: id,119+ pos: [rx + sx * variant.h, 0, rz + sz * variant.h],120+ rotation: r as Placement["rotation"],121+ role: "decor",122+ }));123+ return { placements, cells: cellsFor(rx, rz)! };124+ }125+ return empty;126+}@@ -397,6 +397,55 @@ Deno.test("wide bridge: funnel-bracketed straight-wide-hole over the lower line"
397397 assert(bridges > 0, "no wide bridges across the pinned seeds");398398 });399399 400+Deno.test("scenery ring: four curve-wides close a freestanding roundabout", () => {401+ // M13: the off-grid curve-wide family enters as a self-closed ring.402+ // Rules: exactly four pieces of ONE variant, at ring-center offsets403+ // (±h,±h) with the measured closing rotations (0,3,2,1 — every port404+ // flush against its neighbor by construction), standing on ground clear405+ // of the line.406+ const seeds = ["ring-1", "ring-4", "ring-16", "ring-45"];407+ const H: Record<string, number> = {408+ "curve-wide": 0.85,409+ "curve-wide-medium": 1.35,410+ "curve-wide-large": 1.85,411+ "curve-wide-solid-medium": 1.35,412+ "curve-wide-solid-large": 1.85,413+ };414+ let rings = 0;415+ for (const seed of seeds) {416+ const track = generateTrack(seed, { packs: [] }, { skipDecor: true });417+ const pieces = track.placements.filter((p) =>418+ p.pieceId.startsWith("curve-wide")419+ );420+ assert(pieces.length === 4, `${seed}: expected 4 ring pieces`);421+ rings++;422+ const id = pieces[0].pieceId;423+ assert(pieces.every((p) => p.pieceId === id), `${seed}: mixed variants`);424+ const h = H[id];425+ assert(h !== undefined, `${seed}: unknown variant ${id}`);426+ const rx = pieces.reduce((a, p) => a + p.pos[0], 0) / 4;427+ const rz = pieces.reduce((a, p) => a + p.pos[2], 0) / 4;428+ const want = new Map([429+ [`${1},${1}`, 0],430+ [`${-1},${1}`, 3],431+ [`${-1},${-1}`, 2],432+ [`${1},${-1}`, 1],433+ ]);434+ for (const p of pieces) {435+ assert(p.pos[1] === 0, `${seed}: ring piece off the ground`);436+ const sx = Math.round((p.pos[0] - rx) / h);437+ const sz = Math.round((p.pos[2] - rz) / h);438+ assert(439+ Math.abs(p.pos[0] - (rx + sx * h)) < 1e-9 &&440+ Math.abs(p.pos[2] - (rz + sz * h)) < 1e-9 &&441+ want.get(`${sx},${sz}`) === p.rotation,442+ `${seed}: piece at (${p.pos}) breaks the closing pattern`,443+ );444+ }445+ }446+ assert(rings > 0, "no scenery rings across the pinned seeds");447+});448+400449 Deno.test("determinism: same seed → deep-equal track", () => {401450 const a = generateTrack("determinism-check");402451 const b = generateTrack("determinism-check");@@ -13,14 +13,6 @@ const N = Number(Deno.args[0] ?? 10000);
1313 1414 /** Pieces that deliberately never appear, with the reason. */1515 const EXEMPT: Record<string, string> = {16- // OFF-GRID: ports on ±0.85-style bbox faces, lane circles non-concentric17- // and oblique at ports — cannot mate with the 1-unit port lattice.18- // Deferred to a standalone scenery macro.19- "curve-wide": "off-grid family",20- "curve-wide-large": "off-grid family",21- "curve-wide-medium": "off-grid family",22- "curve-wide-solid-large": "off-grid family",23- "curve-wide-solid-medium": "off-grid family",2416 // The marble itself — the client picks one variant (marble-center-high).2517 "marble-low": "marble variant (client uses marble-center-high)",2618 "marble-high": "marble variant",