clone

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

M11: propeller + shock zones (2-cell low-grav gaps, restitution override, converging catch funnel, straight-landing WFC rule)

Dami committed 2bacc99
commit
2bacc996ba23267b0b1c606a526b17f3090da42b
parent
7360d5598681d856b91d69f04299a9c2a5663ccf
author
Dami <dami.etoile@gmail.com>
12 files changed, +259 -94
Msrc/client/endless.ts2+
Msrc/client/physics/engine.ts12+++++
Msrc/client/physics/rail.ts37++++++++++++--
Msrc/client/physics/shim.c12++++-
Msrc/gen/endless/path.ts59+++++++++++++++++------
Msrc/gen/endless/proxies.ts21+++++---
Msrc/gen/endless/world.ts45++++++++++++++---
Msrc/gen/wfc.ts11++--
Mtests/physics_test.ts71+++++++++++++++------------
Mtests/scenario_test.ts79++++++++++++++++++++++--------
Mtools/build_physics.sh2+-
Mtools/physics-debug/phys-deep.ts2+
M src/client/endless.ts +2 -0
M src/client/physics/engine.ts +12 -0
@@ -47,6 +47,7 @@ export interface PhysicsModule {
4747 ): number;4848 _ph_marble_force(fx: number, fy: number, fz: number): void;4949 _ph_marble_mass(): number;50+ _ph_marble_restitution(restitution: number): void;5051 _ph_step(dt: number, substeps: number): void;5152 _ph_marble_state(outPtr: number): void;5253 _malloc(bytes: number): number;
@@ -63,6 +64,8 @@ export interface MarbleState {
6364 }6465 6566 export const MARBLE_RADIUS = 0.2;67+/** Default marble restitution (shim spawn value); shock zones override. */68+export const MARBLE_RESTITUTION = 0.35;6669 /** Marble Blast's gravity at our scale (MBHaxe: _gravity = 20) — adopted6770 * together with the smooth ribbon, per docs/physics.md. */6871 export const GRAVITY_Y = -20;
@@ -133,6 +136,7 @@ export function assistForce(
133136 export class PhysicsEngine {134137 private readonly out: number;135138 private accumulator = 0;139+ private restitution = MARBLE_RESTITUTION;136140 137141 constructor(private readonly m: PhysicsModule) {138142 m._ph_init(GRAVITY_Y);
@@ -197,11 +201,19 @@ export class PhysicsEngine {
197201 return this.m._ph_marble_mass();198202 }199203 204+ /** Shock-absorber override (cached — the zone law calls this per step). */205+ setRestitution(value: number): void {206+ if (value === this.restitution) return;207+ this.restitution = value;208+ this.m._ph_marble_restitution(value);209+ }210+200211 spawnMarble(201212 pos: readonly [number, number, number],202213 vel: readonly [number, number, number],203214 ): void {204215 this.accumulator = 0;216+ this.restitution = MARBLE_RESTITUTION; // shim recreates the shape205217 this.m._ph_spawn_marble(206218 pos[0],207219 pos[1],
M src/client/physics/rail.ts +31 -6
@@ -16,7 +16,7 @@
1616 */1717 import type { LoopPath } from "../../shared/types.ts";1818 import { segLength, segPoint } from "../../gen/geom.ts";19-import { assistForce, GRAVITY_Y } from "./engine.ts";19+import { assistForce, GRAVITY_Y, MARBLE_RESTITUTION } from "./engine.ts";2020 2121 /** Resolved per-station physics modifier. */2222 export interface RailMod {
@@ -26,6 +26,11 @@ export interface RailMod {
2626 grav?: number;2727 /** Ballistic flight (jump gaps): the assist is hands-off entirely. */2828 flight?: boolean;29+ /** Flight gravity scale (propeller zones: Marble Blast helicopter —30+ * an upward force cancels this fraction of gravity mid-flight). */31+ gravScale?: number;32+ /** Marble restitution override (shock-absorber zones at hard landings). */33+ restitution?: number;2934 }3035 3136 export interface Rail {
@@ -51,6 +56,12 @@ const TILE_MODS: Record<string, RailMod> = {
5156 const ZONE_MODS: Record<string, RailMod> = {5257 boost: { speed: 1.9, grav: 1.2 },5358 jump: { flight: true },59+ // Propeller-jump: the helicopter trick — gravity drops to 0.35 during60+ // the (longer) flight, so the same launch speed clears a 2-cell gap.61+ propeller: { flight: true, gravScale: 0.35 },62+ // Shock absorber at hard-landing zones (Marble Blast: 0.01) — the63+ // driver applies it to the marble while the nearest station is inside.64+ shock: { restitution: 0.02 },5465 };5566 5667 /** The station spacing every driver samples at. */
@@ -68,7 +79,7 @@ export interface RailSource {
6879 export function gapSegmentsOf(seg: RailSource): Set<number> {6980 const out = new Set<number>();7081 seg.windowMods.forEach((m, i) => {71- if (m === "jump") out.add(i);82+ if (m === "jump" || m === "propeller") out.add(i);7283 });7384 return out;7485 }
@@ -114,6 +125,13 @@ export function concatRails(rails: readonly Rail[]): Rail {
114125 return out;115126 }116127 128+/** Marble restitution the station calls for (shock zones override the129+ * default; drivers pass this to PhysicsEngine.setRestitution per step —130+ * the engine caches, so repeats are free). */131+export function stationRestitution(rail: Rail, idx: number): number {132+ return rail.mods[idx]?.restitution ?? MARBLE_RESTITUTION;133+}134+117135 /** Stations of rail lookahead (≈4 world units at RAIL_STEP). */118136 const LOOKAHEAD = 8;119137
@@ -137,10 +155,17 @@ export function stationForce(
137155 const t = rail.tans[idx];138156 if (!t) return null;139157 const mod = rail.mods[idx];140- // Jump gaps: pure ballistics — no assist, no gravity meddling. The141- // launch speed was priced at plan time; touching the marble mid-flight142- // would change where it lands.143- if (mod?.flight) return null;158+ // Jump gaps: ballistic flight — no assist (the launch speed was priced159+ // at plan time; steering mid-flight would change where it lands). A160+ // propeller zone's gravity scaling is part of that pricing: a constant161+ // upward force cancels (1 − gravScale) of gravity for a longer, floatier162+ // arc over a wider gap.163+ if (mod?.flight) {164+ if (mod.gravScale !== undefined && mod.gravScale !== 1) {165+ return [0, (1 - mod.gravScale) * -GRAVITY_Y * mass, 0];166+ }167+ return null;168+ }144169 let climbTy = t[1];145170 let curveAhead = 0;146171 for (let k = 1; k <= LOOKAHEAD; k++) {
M src/client/physics/shim.c +11 -1
M src/gen/endless/path.ts +44 -15
@@ -109,11 +109,13 @@ export interface IslandCrossing {
109109 * "boost" scales the assist's target speed (approaches, chutes); "jump"110110 * marks a gap cell — its realized piece is dropped from the placements and111111 * the collision proxies emit no channel there (ballistic flight, priced by112- * trajectory math at plan time). */112+ * trajectory math at plan time); "propeller" is the 2-cell gap variant113+ * flown under 0.35 gravity (Marble Blast helicopter); "shock" overrides114+ * the marble restitution at hard landings (chute exits). */113115 export interface PlanMod {114116 start: number;115117 len: number;116- kind: "boost" | "jump";118+ kind: "boost" | "jump" | "propeller" | "shock";117119 }118120 119121 export interface SegmentPlan {
@@ -410,7 +412,7 @@ class SegmentBuilder {
410412 case "cornerDrop":411413 return this.tryCornerDrop(rng);412414 case "jump":413- return this.tryJump();415+ return this.tryJump(rng);414416 }415417 for (const attempt of attempts) if (attempt()) return true;416418 return false;
@@ -466,6 +468,19 @@ class SegmentBuilder {
466468 kind: "boost",467469 });468470 }471+ // SHOCK-ABSORBER zone (M11): the two cells after a big chute are the472+ // impact valley — the marble arrives with vy piled on; killing the473+ // bounce there keeps the landing readable (Marble Blast shock474+ // absorber before a hard drop). Cells may not be planned yet; the475+ // mod is cell-indexed, so the windows that eventually cover them476+ // pick it up.477+ if (!climb && totalDrop >= 1.5) {478+ this.mods.push({479+ start: start + option.totalLen,480+ len: 2,481+ kind: "shock",482+ });483+ }469484 let at = start;470485 for (const part of parts) {471486 this.reservations.push({
@@ -566,34 +581,48 @@ class SegmentBuilder {
566581 * land within the first landing cell). The gap cell's claim spans down567582 * to the landing base — the flight band.568583 */569- private tryJump(): boolean {584+ private tryJump(rng: Rng): boolean {570585 const APPROACH = 3;571586 const LANDING = 2;572- // The gap window realizes as slant-b reversed (the only 1-cell 0.5573- // descent that isn't a drop-stack); its centerline keeps the rail574- // continuous through the flight.587+ // PROPELLER variant (2-cell gap flown under 0.35 gravity — same588+ // launch speed, longer floatier arc): drop 1.0 world takes589+ // √(2/7) ≈ 0.535 s, covering ≈ 3.3 of the 4.0-world gap at the boost590+ // target; the catch pan absorbs the shortfall, the brake cap (9 u/s591+ // → 4.8 world) stays on the landing cells.592+ const propeller = rng.chance(0.4);593+ const gapLen = propeller ? 2 : 1;594+ const gapIds = propeller ? ["ramp-long-b", "slant-long-b"] : ["slant-b"];595+ // The gap window realizes as a reversed 0.5-descent (dropped from the596+ // placements later); its centerline keeps the rail continuous through597+ // the flight.575598 if (576599 !this.registry.variants.some((v) =>577- v.id === "slant-b" && v.reversed && v.moves.length === 1600+ gapIds.includes(v.id) && v.reversed && v.moves.length === gapLen578601 )579602 ) return false;580603 const steps: Step[] = [581604 ...this.straightSteps(APPROACH),582- {583- move: "F",605+ // Gap cells mirror commitSlope's band shape: every cell claims down606+ // to the landing base (the flight band), delta steps after cell 0.607+ ...Array.from({ length: gapLen }, (_, j) => ({608+ move: "F" as const,584609 band: [-0.5, CHANNEL_CLEARANCE] as const,585- stepAfter: -0.5,586- },610+ ...(j === 0 ? { stepAfter: -0.5 } : {}),611+ })),587612 ...this.straightSteps(LANDING),588613 ];589614 const start = this.cells.length;590615 if (!this.commitSteps(steps)) return false;591616 this.mods.push({ start, len: APPROACH, kind: "boost" });592- this.mods.push({ start: start + APPROACH, len: 1, kind: "jump" });617+ this.mods.push({618+ start: start + APPROACH,619+ len: gapLen,620+ kind: propeller ? "propeller" : "jump",621+ });593622 this.reservations.push({594623 start: start + APPROACH,595- len: 1,596- pieceIds: ["slant-b"],624+ len: gapLen,625+ pieceIds: gapIds,597626 reversed: true,598627 });599628 return true;
M src/gen/endless/proxies.ts +13 -8
@@ -274,9 +274,13 @@ export function channelProxies(
274274 275275 /** Catch hardware at a jump landing: a pan reaching back under the flight276276 * path (top a hair ABOVE the landing ribbon sheet — arriving below a277- * one-sided mesh means catching its edge forever) plus funnel walls just278- * outside the channel profile. Sized so any launch that cleared the lip279- * at ≥ ~1 u/s lands on pan or ribbon; slower dribbles fall to respawn. */277+ * one-sided mesh means catching its edge forever) plus CONVERGING funnel278+ * walls. The funnel matters: a marble that drifted into the gutter lane279+ * before launch lands off-center, and the landing ribbon's gutter profile280+ * rises above the pan — hitting that first ring edge-on bounces the281+ * marble BACKWARD into the void (found the hard way). The walls narrow282+ * from ±1.05 to ±0.55, shepherding any landing onto the flat floor band283+ * before the ribbon starts. */280284 function emitCatch(landing: Station, out: CollisionProxy[]): void {281285 const fwdX = Math.sin(landing.yaw);282286 const fwdZ = Math.cos(landing.yaw);
@@ -292,16 +296,17 @@ function emitCatch(landing: Station, out: CollisionProxy[]): void {
292296 yaw: landing.yaw,293297 pitch: 0,294298 });299+ const FUNNEL = 0.24; // rad — front opening ±0.55, back ±1.05295300 for (const side of [-1, 1]) {296301 out.push({297302 kind: "slab",298303 center: [299- cx + lx * 0.98 * side,300- floorY + 0.35,301- cz + lz * 0.98 * side,304+ cx + lx * 0.80 * side,305+ floorY + 0.4,306+ cz + lz * 0.80 * side,302307 ],303- half: [0.09, 0.45, 1.05],304- yaw: landing.yaw,308+ half: [0.09, 0.5, 1.05],309+ yaw: landing.yaw - side * FUNNEL,305310 pitch: 0,306311 });307312 }
M src/gen/endless/world.ts +38 -7
@@ -214,6 +214,14 @@ export class EndlessWorld {
214214 reservations: plan.reservations,215215 maxBaseY: Math.max(...plan.cells.map((c) => c.baseY)),216216 };217+ // Jump/propeller trajectory cells: approach (3 before the gap)218+ // through landing (2 after) assume a STRAIGHT channel — a wave219+ // window free-collapsing over them swings the channel laterally220+ // and the ballistic marble misses the catch (found the hard way:221+ // a wave landing edge-bounced the marble back into the void).222+ const noLateral: [number, number][] = plan.mods223+ .filter((m) => m.kind === "jump" || m.kind === "propeller")224+ .map((m) => [m.start - 3, m.start + m.len + 2]);217225 const windows = realize(218226 rng.child("wfc"),219227 skeleton,
@@ -227,8 +235,13 @@ export class EndlessWorld {
227235 tx.isFree(x, z, baseY, baseY + CHANNEL_CLEARANCE),228236 // Physics era: bumps demand crest momentum the assisted marble229237 // doesn't carry — excluded until M11's speed envelope prices230- // momentum vocabulary at plan time.231- exclude: (def) => def.family === "bump",238+ // momentum vocabulary at plan time. Waves stay out of jump239+ // trajectory cells (straight-channel contract).240+ exclude: (def, start) =>241+ def.family === "bump" ||242+ (def.family === "wave" && noLateral.some(([a, b]) =>243+ start < b && a < start + def.moves.length244+ )),232245 },233246 );234247 // Claim the extras into the transaction, then confirm the exit
@@ -294,14 +307,30 @@ export class EndlessWorld {
294307 );295308 296309 // Planner zone kind per window (mods are cell-indexed; windows and297- // path segments are aligned 1:1).310+ // path segments are aligned 1:1). Overlaps resolve by PRIORITY, not311+ // plan order: gap kinds are structural (they drop geometry), boost312+ // must win over a preceding chute's shock zone bleeding into a jump313+ // approach, shock is cosmetic damping.314+ const MOD_PRIORITY: readonly string[] = [315+ "jump",316+ "propeller",317+ "boost",318+ "shock",319+ ];298320 const windowMods = windows.map((w) => {321+ let best: string | null = null;322+ let bestRank: number = MOD_PRIORITY.length;299323 for (const m of plan.mods) {300- if (w.start < m.start + m.len && m.start < w.start + w.len) {301- return m.kind;324+ if (w.start >= m.start + m.len || m.start >= w.start + w.len) {325+ continue;326+ }327+ const rank = MOD_PRIORITY.indexOf(m.kind);328+ if (rank < bestRank) {329+ bestRank = rank;330+ best = m.kind;302331 }303332 }304- return null;333+ return best;305334 });306335 // JUMP gaps: the reserved gap window realized a piece (the WFC needs307336 // the elevation step to be honest), but the set-piece is the ABSENCE
@@ -312,7 +341,9 @@ export class EndlessWorld {
312341 const dropped = new Set<number>();313342 const loopSegs = assembled.loop.segments;314343 for (let wi = 0; wi < loopSegs.length; wi++) {315- if (windowMods[wi] !== "jump") continue;344+ if (windowMods[wi] !== "jump" && windowMods[wi] !== "propeller") {345+ continue;346+ }316347 const startIdx = loopSegs[wi].placementIndex;317348 if (startIdx < 0) continue;318349 let end = assembled.placements.length;
M src/gen/wfc.ts +6 -5
M tests/physics_test.ts +40 -31
@@ -24,6 +24,7 @@ import {
2424 type Rail,2525 railForSegment,2626 stationForce,27+ stationRestitution,2728 } from "../src/client/physics/rail.ts";2829 import type { DecorCatalog } from "../src/gen/decor.ts";2930
@@ -129,6 +130,7 @@ function driveWith(
129130 nearest = i;130131 }131132 }133+ engine.setRestitution(stationRestitution(rail, nearest));132134 const f = stationForce(rail, nearest, st.vel, engine.mass);133135 if (f) engine.force(f[0], f[1], f[2]);134136 });
@@ -179,38 +181,45 @@ Deno.test("physics survivability: assisted marble clears early segments", async
179181 );180182 });181183 182-Deno.test("scenario jump-gap: cold entry launches, flies, and is caught", async () => {184+Deno.test("scenario jump-gap: cold entry launches, flies, and is caught (both kinds)", async () => {183185 module_ = await factory();184- // det-seed segment 0 plans a jump; locate its flight span on the shared185- // rail, spawn the marble COLD (base speed 2.2) just before the boost186- // approach, and require it to cross the gap and keep rolling. This is187- // the envelope MINIMUM — the boost approach alone must build launch188- // speed. (The envelope maximum is bounded by the assist brake at 9 u/s,189- // which the catch pan + first landing cell absorb by construction; hot190- // arrivals are exercised by the 7-seed survivability gate above.)191- const w = new EndlessWorld("det-seed", catalog);192- const rails: Rail[] = [];193- for (let i = 0; i < 3; i++) rails.push(railForSegment(w.get(i)));194- const rail = concatRails(rails);195- const first = rail.mods.findIndex((m) => m?.flight);196- assert(first >= 0, "det-seed early segments lost their jump");197- let last = first;198- while (rail.mods[last + 1]?.flight) last++;199- const flightStart = rail.s[first];200- const flightEnd = rail.s[last];201- const r = drive(202- "det-seed",203- 4,204- 30,205- flightEnd + 10,206- Math.max(0, flightStart - 14),207- );208- assert(!r.fell, "cold-entry marble fell at the jump");209- assert(210- r.reachedS >= flightEnd + 9,211- `cold-entry marble stalled at s=${r.reachedS.toFixed(1)} ` +212- `(flight ${flightStart.toFixed(1)}–${flightEnd.toFixed(1)})`,213- );186+ // One pinned case per gap kind: det-seed segment 0 plans a PROPELLER187+ // (2-cell gap at 0.35 gravity), jump-scan-2 segment 0 a plain JUMP.188+ // Locate the flight span on the shared rail, spawn the marble COLD189+ // (base speed 2.2) just before the boost approach, and require it to190+ // cross the gap and keep rolling. This is the envelope MINIMUM — the191+ // boost approach alone must build launch speed. (The envelope maximum192+ // is bounded by the assist brake at 9 u/s, which the catch pan + the193+ // landing cells absorb by construction; hot arrivals are exercised by194+ // the 7-seed survivability gate above.)195+ for (const { seed, kind } of [196+ { seed: "det-seed", kind: "propeller" },197+ { seed: "jump-scan-2", kind: "jump" },198+ ]) {199+ const w = new EndlessWorld(seed, catalog);200+ const rails: Rail[] = [];201+ for (let i = 0; i < 3; i++) rails.push(railForSegment(w.get(i)));202+ const rail = concatRails(rails);203+ const first = rail.mods.findIndex((m) => m?.flight);204+ assert(first >= 0, `${seed} early segments lost their ${kind}`);205+ let last = first;206+ while (rail.mods[last + 1]?.flight) last++;207+ const flightStart = rail.s[first];208+ const flightEnd = rail.s[last];209+ const r = drive(210+ seed,211+ 4,212+ 30,213+ flightEnd + 10,214+ Math.max(0, flightStart - 14),215+ );216+ assert(!r.fell, `${seed}: cold-entry marble fell at the ${kind}`);217+ assert(218+ r.reachedS >= flightEnd + 9,219+ `${seed}: cold-entry marble stalled at s=${r.reachedS.toFixed(1)} ` +220+ `(${kind} flight ${flightStart.toFixed(1)}–${flightEnd.toFixed(1)})`,221+ );222+ }214223 });215224 216225 Deno.test("physics determinism: identical trajectory hash across runs", async () => {
M tests/scenario_test.ts +59 -20
@@ -201,13 +201,14 @@ for (const s of STREAM_REGRESSIONS) {
201201 // ---------------------------------------------------------------------------202202 203203 Deno.test("jump set-piece: every planned jump obeys the trajectory contract", () => {204- // Rule-based, no time dimension: for every jump the planner commits, the205- // layout MUST be 3 flat straight approach cells under a boost zone → one206- // gap cell (reserved slant-b reversed — realize drops its placement) →207- // ≥2 flat straight landing cells exactly 0.5 track lower. The trajectory208- // math (launch ≈ 6.1 world u/s horizontal, 1.0-world drop in 0.316 s over209- // the 2.0-world gap) is priced INTO this shape; a jump violating it is a210- // marble thrown into the void.204+ // Rule-based, no time dimension: for every gap the planner commits, the205+ // layout MUST be 3 flat straight approach cells under a boost zone → the206+ // gap (jump: 1 cell reserved slant-b reversed; propeller: 2 cells207+ // reserved ramp/slant-long-b reversed, flown at 0.35 gravity) → ≥2 flat208+ // straight landing cells exactly 0.5 track lower. The trajectory math209+ // (launch ≈ 6.1 world u/s; 1.0-world drop in 0.316 s ≈ the 2.0-world210+ // gap, or 0.535 s under propeller gravity ≈ the 4.0-world gap) is priced211+ // INTO this shape; a gap violating it is a marble thrown into the void.211212 const seeds = [212213 "cua-seed-1",213214 "det-seed",
@@ -215,22 +216,37 @@ Deno.test("jump set-piece: every planned jump obeys the trajectory contract", ()
215216 ...Array.from({ length: 12 }, (_, i) => `jump-scan-${i}`),216217 ];217218 let jumps = 0;219+ let propellers = 0;220+ let shocks = 0;218221 for (const seed of seeds) {219222 const w = new EndlessWorld(seed, catalog);220223 for (let i = 0; i < 5; i++) {221224 const seg = w.get(i);222225 const { cells, mods, reservations } = seg.plan;223226 for (const m of mods) {224- if (m.kind !== "jump") continue;225- jumps++;227+ if (m.kind === "shock") {228+ shocks++;229+ continue;230+ }231+ if (m.kind !== "jump" && m.kind !== "propeller") continue;232+ const propeller = m.kind === "propeller";233+ if (propeller) propellers++;234+ else jumps++;226235 const g = m.start;227- assert(m.len === 1, `${seed} seg ${i}: jump zone must be one cell`);236+ const gapLen = propeller ? 2 : 1;237+ const gapIds = propeller238+ ? ["ramp-long-b", "slant-long-b"]239+ : ["slant-b"];240+ assert(241+ m.len === gapLen,242+ `${seed} seg ${i}: ${m.kind} zone must be ${gapLen} cell(s)`,243+ );228244 const res = reservations.find((r) => r.start === g);229245 assert(230- res !== undefined && res.len === 1 &&231- res.pieceIds.length === 1 && res.pieceIds[0] === "slant-b" &&246+ res !== undefined && res.len === gapLen &&247+ res.pieceIds.every((id) => gapIds.includes(id)) &&232248 res.reversed === true,233- `${seed} seg ${i}: gap cell @${g} not reserved as slant-b(rev)`,249+ `${seed} seg ${i}: gap @${g} not reserved as ${gapIds}(rev)`,234250 );235251 const lipY = cells[g].baseY;236252 for (let k = g - 3; k < g; k++) {
@@ -243,20 +259,43 @@ Deno.test("jump set-piece: every planned jump obeys the trajectory contract", ()
243259 mods.some((b) =>244260 b.kind === "boost" && b.start <= g - 3 && b.start + b.len >= g245261 ),246- `${seed} seg ${i}: jump @${g} lacks its boost approach`,262+ `${seed} seg ${i}: ${m.kind} @${g} lacks its boost approach`,247263 );248- assert(249- cells[g].move === "F",250- `${seed} seg ${i}: gap cell must be straight`,251- );252- for (let k = g + 1; k <= g + 2; k++) {264+ for (let k = g; k < g + gapLen; k++) {265+ assert(266+ cells[k].move === "F",267+ `${seed} seg ${i}: gap cell ${k} must be straight`,268+ );269+ }270+ for (let k = g + gapLen; k <= g + gapLen + 1; k++) {253271 assert(254272 cells[k]?.move === "F" && cells[k].baseY === lipY - 0.5,255273 `${seed} seg ${i}: landing cell ${k} not flat at lip−0.5`,256274 );257275 }258276 }277+ // Shock rule: every ≥1.5 chute descent has its impact-valley zone.278+ for (const r of seg.plan.reservations) {279+ const isChuteCap = r.pieceIds.some((id) =>280+ id === "ramp-start-d" || id === "slant-long-d" ||281+ id === "ramp-long-d"282+ );283+ if (!isChuteCap) continue;284+ // Find the 1.5-descent's overall end: the LAST reservation of the285+ // contiguous chain starting here would be complex to recover;286+ // instead assert a shock zone starts within 3 cells after this287+ // window's end (chains are ≤ 2 parts of ≤ 2 cells).288+ assert(289+ seg.plan.mods.some((mm) =>290+ mm.kind === "shock" && mm.start >= r.start + r.len - 1 &&291+ mm.start <= r.start + r.len + 2292+ ),293+ `${seed} seg ${i}: 1.5-chute @${r.start} has no shock zone`,294+ );295+ }259296 }260297 }261- assert(jumps > 0, "no jumps emerged across the scenario corpus");298+ assert(jumps > 0, "no plain jumps emerged across the scenario corpus");299+ assert(propellers > 0, "no propeller jumps emerged across the corpus");300+ assert(shocks > 0, "no shock zones emerged across the corpus");262301 });
M tools/build_physics.sh +1 -1
M tools/physics-debug/phys-deep.ts +2 -0