From 1d497e556e078e0ec6efdd2fb88390bb82d987f3 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Wed, 28 Oct 2026 11:36:30 +0100 Subject: [PATCH] =?UTF-8?q?Metabolism=20M2.8:=20rock=20=E2=80=94=20inert?= =?UTF-8?q?=20resource=20the=20song=20drops=20and=20builders=20quarry,=20c?= =?UTF-8?q?arry=20and=20build=20with;=20livelier=20metabolisms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- metabolism/DESIGN.md | 14 +++ metabolism/EVOLUTION.md | 30 +++++ metabolism/src/render/bitmap.js | 11 ++ metabolism/src/render/view.js | 11 ++ metabolism/src/sim/world.js | 206 ++++++++++++++++++++++++++++++-- metabolism/src/song/palette.js | 4 + metabolism/src/song/rules.js | 28 +++-- metabolism/src/song/strains.js | 37 ++++-- metabolism/test/rock.test.js | 153 ++++++++++++++++++++++++ 9 files changed, 464 insertions(+), 30 deletions(-) create mode 100644 metabolism/test/rock.test.js diff --git a/metabolism/DESIGN.md b/metabolism/DESIGN.md index 5bddfee..3c0de07 100644 --- a/metabolism/DESIGN.md +++ b/metabolism/DESIGN.md @@ -130,6 +130,17 @@ locked with the kick pairs diggers with haulers — and it feeds the other, truce and trade versus skirmish and siege. Society is a property of the mix, at both scales. +### Rock — the inert resource +The map also carries **rock**: height per cell, impassable above a threshold. +The world is born with a deterministic scatter of it; the song is its weather +— strong low/mid hits drop stone (crushing what they land on), strong high +hits shatter it, drops rain it, breakdowns weather it away. Food does not +recharge through rock, so capped ground is barren until opened. Builders +**quarry** it (rock → carried burden, and the grinding yields soil), **carry** +it, and **build** with it — walls rise where the colony works. Rock is the +one thing on the map that is neither food nor life: it shapes where the +geography can go at all. + ### Law — regime per section Section boundaries are found where the 4 Hz feature cloud changes its mind (cheap self-similarity, not an FFT of the FFT), classified into @@ -294,6 +305,9 @@ world behind the `Run` is being rebuilt.* the living well timeline. The world's economy is food-limited: colonies bloom on wells, starve off bare ground, and must keep moving to keep eating. +- **M2.8 — rock** ✔ built: the inert resource — scattered at birth, dropped + and shattered by the song, impassable, quarried by builders into carried + burden and soil, walled where the colony works. See EVOLUTION.md. - **M3 — lens** the camera travels (track / reveal / establish) and reserves cuts for musical punctuation. - **M4 — film** mux frames + PCM to video. diff --git a/metabolism/EVOLUTION.md b/metabolism/EVOLUTION.md index 96ec290..6c2237d 100644 --- a/metabolism/EVOLUTION.md +++ b/metabolism/EVOLUTION.md @@ -177,6 +177,36 @@ holds at 640×360 (~12 ms/frame sim+render). Songs still diverge ≫ seeds on the fingerprint (classMix now sorted — the seed permutes the casting, the song decides its shape). +### M2.8 — rock: the world gains an inert resource ✔ + +The films were alive but weightless: nothing on the map was *stuff*. Rock is +the correction — an inert resource with mass, presence and consequence: + +- **the field** (`world.rock`) — a rock height per cell; above 0.55 the cell + is impassable (advection aims mass at stone and the mass stays, piling + against the wall; diffusion only crosses open edges). Food does not recharge + through rock: capped ground is barren until someone quarries it open. +- **the song's weather** — the map is born with a deterministic scatter of + stone (7–15 blobs, from the seed). Strong low/mid hits DROP rock where they + land (and crush what they land on); strong high hits SHATTER it. The + section regimes gained `rockfall` and `erode` operators: drops rain stone, + breakdowns weather it away. +- **the masons** — builders gained two VM actions: **dig** (a slow bite out + of the rock, which becomes carried burden — and grinding yields soil, a + little food, right where they dug) and **build** (the carried burden rises + as a wall where the ground is clear). The carry lives in per-block registers + of the world, so stone genuinely travels: quarries open, roads run from + them, walls rise where the colony loiters. +- **livelier organisms** — slower metabolisms (longer lives off food), faster + strains, softer bare-ground starvation: agents travel farther between + wells, meet more, and work more. + +*Outcome:* 44 tests green, with new pins: the deterministic scatter; the +song's rockfall outpacing a mute sky; a stone wall holding a crowd west of it +for 10 s straight; quarries opening with soil and burden appearing; carried +rock rising as walls. The strips read it directly: grey masses the colonies +grow around, fronts grinding along them. + ### M3 — the lens: travel, don't cut Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a moving front), *reveal* (pan along a coastline), *establish* (hold wide and diff --git a/metabolism/src/render/bitmap.js b/metabolism/src/render/bitmap.js index adfe479..f483add 100644 --- a/metabolism/src/render/bitmap.js +++ b/metabolism/src/render/bitmap.js @@ -217,6 +217,17 @@ export function composeTrail(world, palette, exposure = 2.2) { sharp[i * 3 + 2] += v * c[2]; } } + // The stone, same as the live renderer draws it. + if (world.rock) { + const stone = palette.stone ?? [0.35, 0.36, 0.4]; + for (let i = 0; i < n; i++) { + const r = world.rock[i]; + if (r < 1e-3) continue; + sharp[i * 3] += r * stone[0] * 2.6; + sharp[i * 3 + 1] += r * stone[1] * 2.6; + sharp[i * 3 + 2] += r * stone[2] * 2.6; + } + } const wide = boxBlur(sharp, F, 2); const tight = boxBlur(sharp, F, 1); const out = new Uint8ClampedArray(n * 4); diff --git a/metabolism/src/render/view.js b/metabolism/src/render/view.js index 68731d0..fa4ab4d 100644 --- a/metabolism/src/render/view.js +++ b/metabolism/src/render/view.js @@ -74,6 +74,17 @@ export class Renderer { sharp[i * 3 + 1] += f * fog[1]; sharp[i * 3 + 2] += f * fog[2]; } + // The stone sits ON the ground: inert, cold, unmistakable. Heavy + // weight — rock tone-maps to a solid grey mass, not a smudge. + const rock = world.rock; + const stone = palette.stone ?? [0.35, 0.36, 0.4]; + for (let i = 0; i < n; i++) { + const r = rock[i]; + if (r < 1e-3) continue; + sharp[i * 3] += r * stone[0] * 2.6; + sharp[i * 3 + 1] += r * stone[1] * 2.6; + sharp[i * 3 + 2] += r * stone[2] * 2.6; + } boxBlur(sharp, this.tmp, this.tight, F, 1); boxBlur(sharp, this.tmp, this.wide, F, 3); const out = this.trailRGBA; diff --git a/metabolism/src/sim/world.js b/metabolism/src/sim/world.js index 668f110..eae19ed 100644 --- a/metabolism/src/sim/world.js +++ b/metabolism/src/sim/world.js @@ -32,7 +32,7 @@ import { Rng } from '../rng.js'; import { wellPos, wellEnv, buildKernel } from './wells.js'; import { runProgram, SENSE_COUNT, GRAD_COUNT, ACTION_COUNT, REGS, - S_FOOD, S_SELF, S_KIN, S_FOE, S_TRAIL, S_LAW, S_HUNGER, S_EDGE, S_TIME, S_TOTAL, S_WELL, S_PEACE, + S_FOOD, S_SELF, S_KIN, S_FOE, S_TRAIL, S_LAW, S_HUNGER, S_EDGE, S_TIME, S_TOTAL, S_WELL, S_PEACE, S_ROCK, G_FOOD, G_SELF, G_KIN, G_FOE, G_WELL, } from '../song/strains.js'; @@ -113,6 +113,12 @@ export class World { this.activity = new Float32Array(32 * 32); + // Rock (M2.8): the inert resource. A barrier the organisms cannot + // cross, that the song drops and shatters, and that builders quarry, + // carry and wall with. rockMask marks the impassable cells. + this.rock = new Float32Array(n); + this.rockMask = new Uint8Array(n); + // Deterministic jitter: static signed noise fields from the seeded RNG, // read every substep. The track's flatness sets how loud it is. this.noise = new Float32Array(n); @@ -148,6 +154,7 @@ export class World { this.blkTrail = Array.from({ length: this.B }, () => new Float32Array(fc)); this.blkBody = Array.from({ length: this.B }, () => new Float32Array(fc)); this.blkTotal = new Float32Array(fc); + this.blkRock = new Float32Array(fc); this.domB = new Int8Array(fc).fill(-1); this.subB = new Int8Array(fc).fill(-1); this.fxX = Array.from({ length: this.m }, () => new Float32Array(fc)); @@ -184,6 +191,8 @@ export class World { } } + this.carry = new Float32Array(this.m * fc); // quarried burden per block + // Strain programs run on this decision grid: registers are the // strains' persistent memory, the action slots carry each decision. this.stMem = new Float32Array(this.m * REGS * fc); @@ -230,6 +239,8 @@ export class World { this.birthsThisFrame = 0; this.deathsThisFrame = 0; + this.seedRocks(); + this.refreshRockMask(); this.foundOpeningBodies(); this.seedInitial(); this.runGenesis(); @@ -388,6 +399,7 @@ export class World { this.program.diplomacy.base * 0.6 + 0.35 * (regime.operators.peace - 1))); const living = this.framePrep(regime); this.inject(this.frame, regime); + this.refreshRockMask(); for (let s = 0; s < this.substeps; s++) this.substep(regime); this.decay(regime, living); this.politics(regime, true); @@ -463,6 +475,76 @@ export class World { for (let i = 0; i < idx.length; i++) res[idx[i]] += w[i] * k; } + // ---- rock --------------------------------------------------------------- + + /** Scatter the world's starting stone: deterministic, from the seed. */ + seedRocks() { + const n = this.program.rock.scatter; + for (let k = 0; k < n; k++) { + const x = this.rng.next(); + const y = this.rng.next(); + const sigma = 0.022 + 0.030 * this.rng.next(); + const amount = 3.5 + 6.5 * this.rng.next(); + this.rockSplat(x, y, sigma, amount); + } + } + + /** + * Add (or, with a negative amount, shave) rock in a Gaussian blob. + * Returns the touched cells so callers can crush or yield. + */ + rockSplat(x, y, spread, amount) { + const F = this.field; + const sigma = Math.max(0.8, spread * F); + const r = Math.ceil(sigma * 2.4); + const cx = Math.round(x * (F - 1)); + const cy = Math.round(y * (F - 1)); + const denom = 1 / (2 * sigma * sigma); + let total = 0; + const idx = []; + const w = []; + for (let dy = -r; dy <= r; dy++) { + const py = cy + dy; + if (py < 0 || py >= F) continue; + for (let dx = -r; dx <= r; dx++) { + const px = cx + dx; + if (px < 0 || px >= F) continue; + const k = Math.exp(-(dx * dx + dy * dy) * denom); + if (k < 0.01) continue; + idx.push(py * F + px); + w.push(k); + total += k; + } + } + if (total <= 0) return { idx: [], w: [], k: 0 }; + const k = amount / total; + const rock = this.rock; + const pop = this.pop; + for (let i = 0; i < idx.length; i++) { + const cell = idx[i]; + if (amount >= 0) { + const before = rock[cell]; + rock[cell] = Math.min(2.2, rock[cell] + w[i] * k); + const gained = rock[cell] - before; + // A rockfall crushes what it lands on. + if (gained > 0.05) { + const crush = Math.max(0, 1 - gained * 0.55); + for (let s = 0; s < this.m; s++) pop[s][cell] *= crush; + } + } else { + rock[cell] = Math.max(0, rock[cell] + w[i] * k); + } + } + return { idx, w, k }; + } + + /** The impassable frontier, recomputed after any rock change. */ + refreshRockMask() { + const rock = this.rock; + const mask = this.rockMask; + for (let i = 0; i < rock.length; i++) mask[i] = rock[i] > 0.55 ? 1 : 0; + } + // ---- wells --------------------------------------------------------------- /** (Re)build a living well's recharge kernel at its current position. */ @@ -545,6 +627,7 @@ export class World { const living = this.framePrep(regime); this.inject(f, regime); + this.refreshRockMask(); for (let s = 0; s < this.substeps; s++) this.substep(regime); this.decay(regime, living); this.politics(regime, false); @@ -616,6 +699,7 @@ export class World { if (!events) return; const F = this.field; const spawn = regime.operators.spawn; + const ops = regime.operators; const vs = this.program.events.voiceStrain; const splash = this.program.events.splash; // The song spawns: how hard a hit births new life where it lands. The @@ -638,6 +722,18 @@ export class World { const ax = Math.max(0, Math.min(31, (e.x * 32) | 0)); const ay = Math.max(0, Math.min(31, (e.y * 32) | 0)); this.activity[ay * 32 + ax] += 0.5 + 2 * e.strength; + // Rock follows the song's own weather: strong low and mid hits + // DROP stone where they land (and crush what they land on); + // strong high hits SHATTER it — the percussion breaks the rock. + if (e.strength > 0.6) { + const rockPol = this.program.rock; + if (e.voice === 2) { + this.rockSplat(e.x, e.y, e.spread * 1.4, -e.strength * 2.4 * rockPol.shatter * ops.erode); + } else { + this.rockSplat(e.x, e.y, e.spread * 1.2, e.strength * 3.2 * ops.rockfall * rockPol.drop); + } + this.activity[ay * 32 + ax] += 1.5; + } // The clearest causality: a hit lands as a ring on the ground. if (e.strength > 0.42) { this.pulses.push({ x: e.x, y: e.y, frame, strength: e.strength, voice: e.voice }); @@ -654,6 +750,7 @@ export class World { */ runStrains(regime) { const FC = this.FC; + const blkRock = this.blkRock; const fc = FC * FC; const m = this.m; const blkPop = this.blkPop; @@ -723,6 +820,7 @@ export class World { ss[S_TOTAL] = clamp01(tot * 0.005); ss[S_WELL] = clamp01(wellField[b]); ss[S_PEACE] = peace; + ss[S_ROCK] = clamp01(blkRock[b] * 0.35); // ---- gradients (sign per field × axis) --------------------- gg[G_FOOD * 2] = Math.sign(gRx); @@ -760,6 +858,8 @@ export class World { actBuf[off + 3 * actFC] = act[3]; // EAT actBuf[off + 4 * actFC] = act[4]; // HIT actBuf[off + 5 * actFC] = act[5]; // SPAWN + actBuf[off + 6 * actFC] = act[6]; // DIG + actBuf[off + 7 * actFC] = act[7]; // BUILD // Synergy: this strain breeds fastest in a mixed, same-body // crowd — the share of the block's population that is kin. @@ -811,6 +911,7 @@ export class World { const noise = this.noise; const noise2 = this.noise2; const n = this.n; + const rock = this.rock; // ---- 1. block sums ---------------------------------------------------- const blkR = this.blkR; @@ -818,14 +919,17 @@ export class World { const blkTrail = this.blkTrail; const blkBody = this.blkBody; const blkTotal = this.blkTotal; + const blkRock = this.blkRock; blkR.fill(0); blkTotal.fill(0); + blkRock.fill(0); for (let s = 0; s < m; s++) blkPop[s].fill(0); for (let b = 0; b < this.B; b++) { blkTrail[b].fill(0); blkBody[b].fill(0); } for (let i = 0; i < n; i++) { const b = coarseOf[i]; const d = density[i]; blkR[b] += res[i]; + blkRock[b] += rock[i]; if (d > 1e-6) { blkTotal[b] += d; for (let s = 0; s < m; s++) blkPop[s][b] += pop[s][i]; @@ -947,7 +1051,7 @@ export class World { demand *= sub; res[i] = demand > 0 ? Math.max(0, r0 - Math.min(r0, demand)) : r0; - const starve = (1 - foodFactor) * (0.35 + 0.9 * ops.spawn) * sub; + const starve = (1 - foodFactor) * (0.28 + 0.9 * ops.spawn) * sub; const society = Math.max(0, 1 - density[i] / societyCeil); for (let s = 0; s < m; s++) { const p = vals[s]; @@ -1005,6 +1109,59 @@ export class World { } } + // ---- rock economy: quarries and walls ----------------------------------- + // Digging takes effort (a slow bite out of the stone) and yields + // soil; the burden rides in the block's carry until the program + // builds with it. This is how the world moves its own ground around. + const rockPol = prog.rock; + const carry = this.carry; + const mask = this.rockMask; + const bs = F / FC; + for (let b = 0; b < fc; b++) { + const bx = (b % FC) * bs; + const by = ((b / FC) | 0) * bs; + for (let s = 0; s < m; s++) { + const base = s * ACTION_COUNT * actFC + b; + const dig = actBuf[base + 6 * actFC]; + const build = actBuf[base + 7 * actFC]; + if (dig > 1e-6) { + let want = dig * rockPol.dig * 1.1 * sub; + let taken = 0; + for (let y = by; y < by + bs; y++) { + for (let x = bx; x < bx + bs; x++) { + if (want <= 1e-9) break; + const i = y * F + x; + const t = Math.min(rock[i], want / (bs * bs)); + if (t <= 0) continue; + rock[i] -= t; + taken += t; + want -= t; + res[i] += t * 0.35; // ground-down rock feeds the ground + } + } + carry[s * fc + b] += taken * 0.85; + } + const c = carry[s * fc + b]; + if (build > 1e-6 && c > 1e-6) { + const rate = build * rockPol.build * 1.1 * sub; + let deposit = Math.min(c, rate + c * 0.04); + let left = deposit; + for (let y = by; y < by + bs; y++) { + for (let x = bx; x < bx + bs; x++) { + if (left <= 1e-9) break; + const i = y * F + x; + if (mask[i]) continue; + const t = Math.min(left / (bs * bs), 2.2 - rock[i]); + if (t <= 0) continue; + rock[i] += t; + left -= t; + } + } + carry[s * fc + b] = c - (deposit - left); + } + } + } + // ---- swap --------------------------------------------------------------- let totalPop = 0; for (let s = 0; s < m; s++) { @@ -1036,6 +1193,7 @@ export class World { const coarseOf = this.coarseOf; const noise = this.noise; const noise2 = this.noise2; + const mask = this.rockMask; const n = this.n; dst.fill(0); for (let i = 0; i < n; i++) { @@ -1053,8 +1211,12 @@ export class World { const mx = (leave * Math.abs(vx)) / w; const my = (leave * Math.abs(vy)) / w; dst[i] += v - leave; - dst[vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i] += mx; - dst[vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i] += my; + // Rock is a wall: mass aimed at a stone cell stays put and + // piles against it. + const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i; + const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i; + dst[mask[tx] ? i : tx] += mx; + dst[mask[ty] ? i : ty] += my; if (track) { const ax = ((i % F) * 32 / F) | 0; @@ -1066,13 +1228,23 @@ export class World { dst[i] += v; } - // Diffusion through the four neighbours (conservative). - const d = (src[nUp[i]] + src[nDown[i]] + src[nLeft[i]] + src[nRight[i]] - 4 * v) * diff * sub; - dst[i] += d * 0.5; - dst[nUp[i]] -= d * 0.125; - dst[nDown[i]] -= d * 0.125; - dst[nLeft[i]] -= d * 0.125; - dst[nRight[i]] -= d * 0.125; + // Diffusion through the OPEN neighbours only: rock reflects the + // haze instead of drinking it (still conservative overall). + let open = 0; + let nb = 0; + if (!mask[nUp[i]]) { nb += src[nUp[i]]; open++; } + if (!mask[nDown[i]]) { nb += src[nDown[i]]; open++; } + if (!mask[nLeft[i]]) { nb += src[nLeft[i]]; open++; } + if (!mask[nRight[i]]) { nb += src[nRight[i]]; open++; } + if (open > 0) { + const d = (nb - open * v) * diff * sub; + dst[i] += d * 0.5; + const share = d * 0.5 / open; + if (!mask[nUp[i]]) dst[nUp[i]] -= share; + if (!mask[nDown[i]]) dst[nDown[i]] -= share; + if (!mask[nLeft[i]]) dst[nLeft[i]] -= share; + if (!mask[nRight[i]]) dst[nRight[i]] -= share; + } } } @@ -1252,14 +1424,24 @@ export class World { const actK = Math.pow(0.5, dt / 1.4); for (let i = 0; i < act.length; i++) act[i] *= actK; - // Wells recharge to a ceiling; the section's energy sets the rate. + // Erosion: the section's erode operator sets how fast the stone + // weathers away. Breakdowns clear the ground; drops raise it. + const rock = this.rock; + const rockHalf = 60 / (0.4 + 0.8 * ops.erode); + const rockK = Math.pow(0.5, dt / rockHalf); + for (let i = 0; i < rock.length; i++) rock[i] *= rockK; + + // Wells recharge to a ceiling — but not through stone: rock capped + // ground stays barren until someone quarries it open. const scale = (0.22 + 0.7 * regime.energy) * dt; + const mask = this.rockMask; for (const { well, env } of living) { const add = well.rate * scale * env / well.peak; const idx = well.kernel.idx; const w = well.kernel.w; for (let i = 0; i < idx.length; i++) { const cell = idx[i]; + if (mask[cell]) continue; res[cell] = Math.min(1.6, res[cell] + add * w[i]); } } diff --git a/metabolism/src/song/palette.js b/metabolism/src/song/palette.js index 4788f97..a1b25fb 100644 --- a/metabolism/src/song/palette.js +++ b/metabolism/src/song/palette.js @@ -55,12 +55,16 @@ export function buildPalette(summary, rng) { const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright); const fog = hsl(hue + 0.5, 0.6, 0.075); + // The stone: rock reads as a cold, desaturated mass — clearly of the + // ground, clearly not alive, clearly not food. + const stone = hsl(hue + 0.5, 0.12, 0.44 + 0.06 * bright); return { hue, sat, bg, fog, + stone, classes: [c0, c1, c2], bodies, // Trails sit below the agents: the world is dimmer than what moves in it. diff --git a/metabolism/src/song/rules.js b/metabolism/src/song/rules.js index cf7cce8..3d20293 100644 --- a/metabolism/src/song/rules.js +++ b/metabolism/src/song/rules.js @@ -36,18 +36,18 @@ const lerp = (a, b, t) => a + (b - a) * t; * speed / growth / deposit / split / spawn */ export const REGIMES = { - intro: { chemo: 0.7, disperse: 0.9, home: 1.6, turbulence: 0.5, speed: 0.6, growth: 0.7, deposit: 0.8, split: 0.9, spawn: 0.5, peace: 1.4 }, - build: { chemo: 0.9, disperse: 1.1, home: 0.9, turbulence: 0.8, speed: 1.0, growth: 1.2, deposit: 1.0, split: 1.0, spawn: 1.2, peace: 0.9 }, - drop: { chemo: 1.5, disperse: 1.7, home: 0.2, turbulence: 2.4, speed: 1.7, growth: 1.7, deposit: 0.6, split: 1.6, spawn: 2.4, peace: 0.25 }, - breakdown: { chemo: 0.4, disperse: 0.15, home: 2.2, turbulence: 0.15, speed: 0.4, growth: 0.3, deposit: 2.2, split: 1.4, spawn: 0.25, peace: 2.0 }, - sustain: { chemo: 0.9, disperse: 1.0, home: 0.8, turbulence: 0.8, speed: 0.9, growth: 0.9, deposit: 1.1, split: 1.0, spawn: 0.8, peace: 1.0 }, - outro: { chemo: 0.5, disperse: 0.5, home: 2.0, turbulence: 0.3, speed: 0.5, growth: 0.4, deposit: 1.8, split: 1.2, spawn: 0.3, peace: 1.6 }, + intro: { chemo: 0.7, disperse: 0.9, home: 1.6, turbulence: 0.5, speed: 0.6, growth: 0.7, deposit: 0.8, split: 0.9, spawn: 0.5, peace: 1.4, rockfall: 0.4, erode: 0.5 }, + build: { chemo: 0.9, disperse: 1.1, home: 0.9, turbulence: 0.8, speed: 1.0, growth: 1.2, deposit: 1.0, split: 1.0, spawn: 1.2, peace: 0.9, rockfall: 1.0, erode: 0.6 }, + drop: { chemo: 1.5, disperse: 1.7, home: 0.2, turbulence: 2.4, speed: 1.7, growth: 1.7, deposit: 0.6, split: 1.6, spawn: 2.4, peace: 0.25, rockfall: 2.2, erode: 0.4 }, + breakdown: { chemo: 0.4, disperse: 0.15, home: 2.2, turbulence: 0.15, speed: 0.4, growth: 0.3, deposit: 2.2, split: 1.4, spawn: 0.25, peace: 2.0, rockfall: 0.15, erode: 1.8 }, + sustain: { chemo: 0.9, disperse: 1.0, home: 0.8, turbulence: 0.8, speed: 0.9, growth: 0.9, deposit: 1.1, split: 1.0, spawn: 0.8, peace: 1.0, rockfall: 0.9, erode: 0.8 }, + outro: { chemo: 0.5, disperse: 0.5, home: 2.0, turbulence: 0.3, speed: 0.5, growth: 0.4, deposit: 1.8, split: 1.2, spawn: 0.3, peace: 1.6, rockfall: 0.3, erode: 1.4 }, }; -const OP_KEYS = ['chemo', 'disperse', 'home', 'turbulence', 'speed', 'growth', 'deposit', 'split', 'spawn', 'peace']; +const OP_KEYS = ['chemo', 'disperse', 'home', 'turbulence', 'speed', 'growth', 'deposit', 'split', 'spawn', 'peace', 'rockfall', 'erode']; // Ceilings so a song's character amplifies a regime without blowing it up. -const OP_MAX = { chemo: 2.0, disperse: 2.0, home: 2.4, turbulence: 2.2, speed: 1.8, growth: 1.8, deposit: 2.4, split: 1.8, spawn: 2.2, peace: 2.4 }; +const OP_MAX = { chemo: 2.0, disperse: 2.0, home: 2.4, turbulence: 2.2, speed: 1.8, growth: 1.8, deposit: 2.4, split: 1.8, spawn: 2.2, peace: 2.4, rockfall: 2.6, erode: 2.4 }; /** * How the track's own character scales each operator family — this is what @@ -71,6 +71,8 @@ function songScales(summary) { split: 0.85 + 0.35 * density, spawn: 0.80 + 0.50 * beat, peace: 0.85 + 0.30 * tone, // tonal tracks settle easier + rockfall: 0.55 + 0.90 * density, // percussive tracks rain rock + erode: 0.80 + 0.40 * tone, // tonal tracks weather it }; } @@ -167,6 +169,16 @@ export function compileProgram(summary, genome, strains) { splash: strains.map((s) => SPLASH_BY_ROLE[s.role] ?? 0.5), voiceStrain, }, + // Rock: the inert resource. Strong low/mid hits DROP it, strong high + // hits SHATTER it; builders quarry it (dig → carry → wall, and the + // grinding yields soil). A busy, percussive track throws more stone. + rock: { + drop: 0.9 * (0.55 + 0.9 * clamp01(summary.onsetDensity / 6)), + shatter: 1.1, + dig: 0.9, + build: 0.8 + 0.5 * clamp01(1 - summary.flatness), + scatter: 7 + Math.round(8 * clamp01(summary.onsetDensity / 6)), + }, }; } diff --git a/metabolism/src/song/strains.js b/metabolism/src/song/strains.js index 25dcdde..dfd7074 100644 --- a/metabolism/src/song/strains.js +++ b/metabolism/src/song/strains.js @@ -28,7 +28,8 @@ export const S_TIME = 8; export const S_TOTAL = 9; export const S_WELL = 10; // nearest living well's pull export const S_PEACE = 11; // section's peace operator (war ↔ truce) -export const SENSE_COUNT = 12; +export const S_ROCK = 12; // rock under this block: the quarriable ground +export const SENSE_COUNT = 13; // Gradient fields, two slots each (x, y): food, own strain, kin, foe, well. export const G_FOOD = 0; @@ -47,7 +48,9 @@ export const A_HIT = 4; export const A_SPAWN = 5; export const A_DRIFT = 6; // gradual direction change following a gradient export const A_ACCELERATE = 7; // speed up / slow down -export const ACTION_COUNT = 8; +export const A_DIG = 8; // quarry rock here (rock → carried → soil) +export const A_BUILD = 9; // deposit carried rock as a wall +export const ACTION_COUNT = 10; export const REGS = 6; export const MAX_INSTR = 32; @@ -89,23 +92,23 @@ class Asm { const ROLES = { builder: { - base: { speed: 0.030, deposit: 1.50, cap: 1.25, growth: 0.90, eat: 0.036 }, + base: { speed: 0.038, deposit: 1.50, cap: 1.25, growth: 0.90, eat: 0.032 }, hungryAt: 0.50, spawnW: 0.30, }, grazer: { - base: { speed: 0.075, deposit: 0.75, cap: 1.00, growth: 1.00, eat: 0.050 }, + base: { speed: 0.095, deposit: 0.75, cap: 1.00, growth: 1.00, eat: 0.042 }, hungryAt: 0.42, spawnW: 0.45, }, scout: { - base: { speed: 0.125, deposit: 0.18, cap: 0.80, growth: 1.05, eat: 0.055 }, + base: { speed: 0.155, deposit: 0.18, cap: 0.80, growth: 1.05, eat: 0.046 }, hungryAt: 0.62, spawnW: 0.60, }, sentinel: { - base: { speed: 0.058, deposit: 0.55, cap: 1.00, growth: 0.85, eat: 0.045 }, + base: { speed: 0.070, deposit: 0.55, cap: 1.00, growth: 0.85, eat: 0.040 }, hungryAt: 0.50, spawnW: 0.25, foeAt: 0.10, }, breeder: { - base: { speed: 0.050, deposit: 0.50, cap: 0.90, growth: 1.25, eat: 0.040 }, + base: { speed: 0.062, deposit: 0.50, cap: 0.90, growth: 1.25, eat: 0.036 }, hungryAt: 0.55, spawnW: 0.85, kinAt: 0.12, }, }; @@ -189,14 +192,25 @@ export function compileStrains(summary, genome, rng) { const fed = asm.label(); asm.jump(hungryPatch, fed); if (role === 'builder') { - // The ink-dropper: inscribes, but never quite stops — a slow orbit - // around its ground keeps the colony's edge alive. - asm.act(A_EXCRETE, 3, 1.35); + // The mason: where rock lies underfoot it quarries (rock becomes + // carried burden and, ground down, soil); where the ground is + // clear it walls — the carried rock rises as the colony's + // structure. It never quite stops orbiting its ground. + asm.sense(4, S_ROCK, 1); + const quarry = asm.ifgt(4, 0.12); + asm.act(A_BUILD, 1, 1.2); + asm.act(A_EXCRETE, 3, 1.1); asm.grad(4, G_SELF, 0, 0.4); asm.grad(5, G_SELF, 1, 0.4); asm.act(A_DRIFT, 4, 0.8); asm.act(A_SPAWN, 1, spec.spawnW); asm.halt(); + const dig = asm.label(); + asm.jump(quarry, dig); + asm.act(A_DIG, 4, 1); + asm.grad(4, G_FOOD, 0, 0.3); + asm.act(A_MOVE_X, 4, 0.3); + asm.halt(); } else if (role === 'grazer') { // Commutes: follows the food edge, leaves a road behind. asm.grad(4, G_FOOD, 0, 0.7); @@ -297,6 +311,7 @@ export function runProgram(memBuf, off, senses, grads, act, code) { let steps = 0; const n = code.length; act[0] = 0; act[1] = 0; act[2] = 0; act[3] = 1; act[4] = 0; act[5] = 0; + act[6] = 0; act[7] = 0; const mem = memBuf; while (steps++ < MAX_STEPS && pc < n) { const ins = code[pc]; @@ -334,6 +349,8 @@ export function runProgram(memBuf, off, senses, grads, act, code) { case A_SPAWN: act[5] += Math.max(0, drive) * w * 0.012; break; case A_DRIFT: act[0] += drive * w * 0.1; act[1] += drive * w * 0.1; break; case A_ACCELERATE: act[3] *= drive * w + 1; break; + case A_DIG: act[6] += Math.max(0, drive) * w; break; + case A_BUILD: act[7] += Math.max(0, drive) * w; break; default: break; } break; diff --git a/metabolism/test/rock.test.js b/metabolism/test/rock.test.js new file mode 100644 index 0000000..6af60b1 --- /dev/null +++ b/metabolism/test/rock.test.js @@ -0,0 +1,153 @@ +// M2.8 — rock: the inert resource. It is scattered at the world's birth, +// dropped and shattered by the song, impassable to the population, and +// quarried by builders — carried, walled, ground into soil. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; +import { analyse } from '../src/audio/analyse.js'; +import { decodeWav, mono } from '../src/audio/wav.js'; +import { buildSong } from '../src/song/song.js'; +import { Run } from '../src/film.js'; +import { OP, S_ROCK, A_DIG, A_BUILD } from '../src/song/strains.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BANK = resolve(HERE, '../..', 'flow-state/test/songs'); + +function load(name, seed = 12345) { + const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`))); + return buildSong(analyse(mono(wav.channels), wav.sampleRate), seed); +} + +function run(name, seed = 12345, field = 64) { + const song = load(name, seed); + const r = new Run(song, { fps: 30, seed, width: 240, height: 135, field, genesis: 0 }); + return r; +} + +const rockMass = (w) => { + let s = 0; + for (let i = 0; i < w.n; i++) s += w.rock[i]; + return s; +}; +const maskedCells = (w) => { + let s = 0; + for (let i = 0; i < w.n; i++) s += w.rockMask[i]; + return s; +}; + +test('the world is born with stone in it (deterministically)', () => { + const a = run('drone').world; + const b = run('drone').world; + assert.ok(rockMass(a) > 5, `scatter should hold stone (${rockMass(a).toFixed(1)})`); + assert.ok(maskedCells(a) > 0, 'some ground is impassable from birth'); + assert.deepEqual(Array.from(a.rock), Array.from(b.rock)); +}); + +test('the song drops rock on heavy hits and shatters it on high ones', () => { + const live = run('runner').world; + const mute = run('runner').world; + mute.program.rock.drop = 0; + mute.program.rock.shatter = 0; + for (let f = 0; f < 600; f++) { live.step(); mute.step(); } + assert.ok(rockMass(live) > rockMass(mute) * 1.3, + `rockfall should outpace a mute sky (live ${rockMass(live).toFixed(1)} vs ${rockMass(mute).toFixed(1)})`); +}); + +test('rock is a wall: the population cannot cross it', () => { + const w = run('drone').world; + const F = w.field; + // A solid vertical wall of stone down the middle, north to south. + for (let y = 0; y < F; y++) { + for (let x = 30; x < 34; x++) w.rock[y * F + x] = 1.5; + } + w.refreshRockMask(); + // A crowd planted on the west side, food on both sides so the only + // reason not to spread east is the wall. + for (let y = 8; y < 56; y++) { + for (let x = 4; x < 26; x++) { + const i = y * F + x; + for (let s = 0; s < w.m; s++) w.pop[s][i] = 2; + w.alleg[0][i] = 2 * w.m; + w.resource[i] = 1.2; + } + for (let x = 40; x < 60; x++) w.resource[y * F + x] = 1.2; + } + w.bodies[0].cx = 0.2; w.bodies[0].cy = 0.5; w.bodies[0].alive = true; + if (!w.aliveBodies.includes(0)) w.aliveBodies.push(0); + w.recount(); + + for (let f = 0; f < 300; f++) w.step(); + + let east = 0; + for (let y = 0; y < F; y++) { + for (let x = 34; x < F; x++) east += w.density[y * F + x]; + } + assert.ok(east < w.count * 0.02, + `the wall must hold (${east.toFixed(1)} of ${w.count.toFixed(0)} leaked east)`); +}); + +test('builders quarry: rock goes down, soil and burden appear', () => { + const w = run('dusk').world; + const F = w.field; + // Every strain digs where rock lies. + for (const s of w.strains) { + s.code = [ + [OP.SENSE, 4, S_ROCK, 1], + [OP.IFGT, 4, 0.05, 3], + [OP.HALT], + [OP.ACT, A_DIG, 4, 1], + [OP.HALT], + ]; + } + // A rich seam under the crowd. + for (let y = 20; y < 40; y++) { + for (let x = 20; x < 40; x++) { + const i = y * F + x; + w.rock[i] = 1.4; + for (let s = 0; s < w.m; s++) w.pop[s][i] = 3; + w.alleg[0][i] = 3 * w.m; + } + } + w.refreshRockMask(); + w.bodies[0].alive = true; w.bodies[0].cx = 0.45; w.bodies[0].cy = 0.45; + if (!w.aliveBodies.includes(0)) w.aliveBodies.push(0); + w.recount(); + + const before = rockMass(w); + let foodBefore = 0; + for (let i = 0; i < w.n; i++) foodBefore += w.resource[i]; + for (let f = 0; f < 120; f++) w.step(); + const after = rockMass(w); + let foodAfter = 0; + for (let i = 0; i < w.n; i++) foodAfter += w.resource[i]; + let carry = 0; + for (let s = 0; s < w.m; s++) for (let b = 0; b < w.fc; b++) carry += w.carry[s * w.fc + b]; + + assert.ok(after < before - 1, `quarries must open (${before.toFixed(1)} → ${after.toFixed(1)})`); + assert.ok(carry > 0.2, `the burden must be carried (${carry.toFixed(2)})`); + assert.ok(foodAfter > foodBefore, `grinding yields soil (${foodBefore.toFixed(1)} → ${foodAfter.toFixed(1)})`); +}); + +test('builders build: carried rock rises as walls', () => { + const w = run('dusk').world; + // Every strain builds. + for (const s of w.strains) { + s.code = [ + [OP.SET, 4, 1], + [OP.ACT, A_BUILD, 4, 1], + [OP.HALT], + ]; + } + // A loaded block in the middle of open ground. + const F = w.field; + const b = w.coarseOf[30 * F + 30]; + for (let s = 0; s < w.m; s++) w.carry[s * w.fc + b] = 5; + const before = rockMass(w); + for (let f = 0; f < 60; f++) w.step(); + const after = rockMass(w); + assert.ok(after > before + 0.5, + `carried rock must rise (${before.toFixed(2)} → ${after.toFixed(2)})`); +});