Metabolism M2.8: rock — inert resource the song drops and builders quarry, carry and build with; livelier metabolisms

This commit is contained in:
Dejvino
2026-10-28 11:36:30 +01:00
parent 8abefb16b7
commit 1d497e556e
9 changed files with 464 additions and 30 deletions
+14
View File
@@ -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 other, truce and trade versus skirmish and siege. Society is a property of
the mix, at both scales. 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 ### Law — regime per section
Section boundaries are found where the 4 Hz feature cloud changes its mind Section boundaries are found where the 4 Hz feature cloud changes its mind
(cheap self-similarity, not an FFT of the FFT), classified into (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 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 bloom on wells, starve off bare ground, and must keep moving to keep
eating. 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 - **M3 — lens** the camera travels (track / reveal / establish) and reserves
cuts for musical punctuation. cuts for musical punctuation.
- **M4 — film** mux frames + PCM to video. - **M4 — film** mux frames + PCM to video.
+30
View File
@@ -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 the fingerprint (classMix now sorted — the seed permutes the casting, the
song decides its shape). 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 ### M3 — the lens: travel, don't cut
Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a 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 moving front), *reveal* (pan along a coastline), *establish* (hold wide and
+11
View File
@@ -217,6 +217,17 @@ export function composeTrail(world, palette, exposure = 2.2) {
sharp[i * 3 + 2] += v * c[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 wide = boxBlur(sharp, F, 2);
const tight = boxBlur(sharp, F, 1); const tight = boxBlur(sharp, F, 1);
const out = new Uint8ClampedArray(n * 4); const out = new Uint8ClampedArray(n * 4);
+11
View File
@@ -74,6 +74,17 @@ export class Renderer {
sharp[i * 3 + 1] += f * fog[1]; sharp[i * 3 + 1] += f * fog[1];
sharp[i * 3 + 2] += f * fog[2]; 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.tight, F, 1);
boxBlur(sharp, this.tmp, this.wide, F, 3); boxBlur(sharp, this.tmp, this.wide, F, 3);
const out = this.trailRGBA; const out = this.trailRGBA;
+193 -11
View File
@@ -32,7 +32,7 @@ import { Rng } from '../rng.js';
import { wellPos, wellEnv, buildKernel } from './wells.js'; import { wellPos, wellEnv, buildKernel } from './wells.js';
import { import {
runProgram, SENSE_COUNT, GRAD_COUNT, ACTION_COUNT, REGS, 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, G_FOOD, G_SELF, G_KIN, G_FOE, G_WELL,
} from '../song/strains.js'; } from '../song/strains.js';
@@ -113,6 +113,12 @@ export class World {
this.activity = new Float32Array(32 * 32); 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, // Deterministic jitter: static signed noise fields from the seeded RNG,
// read every substep. The track's flatness sets how loud it is. // read every substep. The track's flatness sets how loud it is.
this.noise = new Float32Array(n); this.noise = new Float32Array(n);
@@ -148,6 +154,7 @@ export class World {
this.blkTrail = Array.from({ length: this.B }, () => new Float32Array(fc)); this.blkTrail = Array.from({ length: this.B }, () => new Float32Array(fc));
this.blkBody = Array.from({ length: this.B }, () => new Float32Array(fc)); this.blkBody = Array.from({ length: this.B }, () => new Float32Array(fc));
this.blkTotal = new Float32Array(fc); this.blkTotal = new Float32Array(fc);
this.blkRock = new Float32Array(fc);
this.domB = new Int8Array(fc).fill(-1); this.domB = new Int8Array(fc).fill(-1);
this.subB = new Int8Array(fc).fill(-1); this.subB = new Int8Array(fc).fill(-1);
this.fxX = Array.from({ length: this.m }, () => new Float32Array(fc)); 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 // Strain programs run on this decision grid: registers are the
// strains' persistent memory, the action slots carry each decision. // strains' persistent memory, the action slots carry each decision.
this.stMem = new Float32Array(this.m * REGS * fc); this.stMem = new Float32Array(this.m * REGS * fc);
@@ -230,6 +239,8 @@ export class World {
this.birthsThisFrame = 0; this.birthsThisFrame = 0;
this.deathsThisFrame = 0; this.deathsThisFrame = 0;
this.seedRocks();
this.refreshRockMask();
this.foundOpeningBodies(); this.foundOpeningBodies();
this.seedInitial(); this.seedInitial();
this.runGenesis(); this.runGenesis();
@@ -388,6 +399,7 @@ export class World {
this.program.diplomacy.base * 0.6 + 0.35 * (regime.operators.peace - 1))); this.program.diplomacy.base * 0.6 + 0.35 * (regime.operators.peace - 1)));
const living = this.framePrep(regime); const living = this.framePrep(regime);
this.inject(this.frame, regime); this.inject(this.frame, regime);
this.refreshRockMask();
for (let s = 0; s < this.substeps; s++) this.substep(regime); for (let s = 0; s < this.substeps; s++) this.substep(regime);
this.decay(regime, living); this.decay(regime, living);
this.politics(regime, true); 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; 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 --------------------------------------------------------------- // ---- wells ---------------------------------------------------------------
/** (Re)build a living well's recharge kernel at its current position. */ /** (Re)build a living well's recharge kernel at its current position. */
@@ -545,6 +627,7 @@ export class World {
const living = this.framePrep(regime); const living = this.framePrep(regime);
this.inject(f, regime); this.inject(f, regime);
this.refreshRockMask();
for (let s = 0; s < this.substeps; s++) this.substep(regime); for (let s = 0; s < this.substeps; s++) this.substep(regime);
this.decay(regime, living); this.decay(regime, living);
this.politics(regime, false); this.politics(regime, false);
@@ -616,6 +699,7 @@ export class World {
if (!events) return; if (!events) return;
const F = this.field; const F = this.field;
const spawn = regime.operators.spawn; const spawn = regime.operators.spawn;
const ops = regime.operators;
const vs = this.program.events.voiceStrain; const vs = this.program.events.voiceStrain;
const splash = this.program.events.splash; const splash = this.program.events.splash;
// The song spawns: how hard a hit births new life where it lands. The // 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 ax = Math.max(0, Math.min(31, (e.x * 32) | 0));
const ay = Math.max(0, Math.min(31, (e.y * 32) | 0)); const ay = Math.max(0, Math.min(31, (e.y * 32) | 0));
this.activity[ay * 32 + ax] += 0.5 + 2 * e.strength; 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. // The clearest causality: a hit lands as a ring on the ground.
if (e.strength > 0.42) { if (e.strength > 0.42) {
this.pulses.push({ x: e.x, y: e.y, frame, strength: e.strength, voice: e.voice }); 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) { runStrains(regime) {
const FC = this.FC; const FC = this.FC;
const blkRock = this.blkRock;
const fc = FC * FC; const fc = FC * FC;
const m = this.m; const m = this.m;
const blkPop = this.blkPop; const blkPop = this.blkPop;
@@ -723,6 +820,7 @@ export class World {
ss[S_TOTAL] = clamp01(tot * 0.005); ss[S_TOTAL] = clamp01(tot * 0.005);
ss[S_WELL] = clamp01(wellField[b]); ss[S_WELL] = clamp01(wellField[b]);
ss[S_PEACE] = peace; ss[S_PEACE] = peace;
ss[S_ROCK] = clamp01(blkRock[b] * 0.35);
// ---- gradients (sign per field × axis) --------------------- // ---- gradients (sign per field × axis) ---------------------
gg[G_FOOD * 2] = Math.sign(gRx); gg[G_FOOD * 2] = Math.sign(gRx);
@@ -760,6 +858,8 @@ export class World {
actBuf[off + 3 * actFC] = act[3]; // EAT actBuf[off + 3 * actFC] = act[3]; // EAT
actBuf[off + 4 * actFC] = act[4]; // HIT actBuf[off + 4 * actFC] = act[4]; // HIT
actBuf[off + 5 * actFC] = act[5]; // SPAWN 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 // Synergy: this strain breeds fastest in a mixed, same-body
// crowd — the share of the block's population that is kin. // crowd — the share of the block's population that is kin.
@@ -811,6 +911,7 @@ export class World {
const noise = this.noise; const noise = this.noise;
const noise2 = this.noise2; const noise2 = this.noise2;
const n = this.n; const n = this.n;
const rock = this.rock;
// ---- 1. block sums ---------------------------------------------------- // ---- 1. block sums ----------------------------------------------------
const blkR = this.blkR; const blkR = this.blkR;
@@ -818,14 +919,17 @@ export class World {
const blkTrail = this.blkTrail; const blkTrail = this.blkTrail;
const blkBody = this.blkBody; const blkBody = this.blkBody;
const blkTotal = this.blkTotal; const blkTotal = this.blkTotal;
const blkRock = this.blkRock;
blkR.fill(0); blkR.fill(0);
blkTotal.fill(0); blkTotal.fill(0);
blkRock.fill(0);
for (let s = 0; s < m; s++) blkPop[s].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 b = 0; b < this.B; b++) { blkTrail[b].fill(0); blkBody[b].fill(0); }
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const b = coarseOf[i]; const b = coarseOf[i];
const d = density[i]; const d = density[i];
blkR[b] += res[i]; blkR[b] += res[i];
blkRock[b] += rock[i];
if (d > 1e-6) { if (d > 1e-6) {
blkTotal[b] += d; blkTotal[b] += d;
for (let s = 0; s < m; s++) blkPop[s][b] += pop[s][i]; for (let s = 0; s < m; s++) blkPop[s][b] += pop[s][i];
@@ -947,7 +1051,7 @@ export class World {
demand *= sub; demand *= sub;
res[i] = demand > 0 ? Math.max(0, r0 - Math.min(r0, demand)) : r0; 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); const society = Math.max(0, 1 - density[i] / societyCeil);
for (let s = 0; s < m; s++) { for (let s = 0; s < m; s++) {
const p = vals[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 --------------------------------------------------------------- // ---- swap ---------------------------------------------------------------
let totalPop = 0; let totalPop = 0;
for (let s = 0; s < m; s++) { for (let s = 0; s < m; s++) {
@@ -1036,6 +1193,7 @@ export class World {
const coarseOf = this.coarseOf; const coarseOf = this.coarseOf;
const noise = this.noise; const noise = this.noise;
const noise2 = this.noise2; const noise2 = this.noise2;
const mask = this.rockMask;
const n = this.n; const n = this.n;
dst.fill(0); dst.fill(0);
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
@@ -1053,8 +1211,12 @@ export class World {
const mx = (leave * Math.abs(vx)) / w; const mx = (leave * Math.abs(vx)) / w;
const my = (leave * Math.abs(vy)) / w; const my = (leave * Math.abs(vy)) / w;
dst[i] += v - leave; dst[i] += v - leave;
dst[vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i] += mx; // Rock is a wall: mass aimed at a stone cell stays put and
dst[vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i] += my; // 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) { if (track) {
const ax = ((i % F) * 32 / F) | 0; const ax = ((i % F) * 32 / F) | 0;
@@ -1066,13 +1228,23 @@ export class World {
dst[i] += v; dst[i] += v;
} }
// Diffusion through the four neighbours (conservative). // Diffusion through the OPEN neighbours only: rock reflects the
const d = (src[nUp[i]] + src[nDown[i]] + src[nLeft[i]] + src[nRight[i]] - 4 * v) * diff * sub; // 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; dst[i] += d * 0.5;
dst[nUp[i]] -= d * 0.125; const share = d * 0.5 / open;
dst[nDown[i]] -= d * 0.125; if (!mask[nUp[i]]) dst[nUp[i]] -= share;
dst[nLeft[i]] -= d * 0.125; if (!mask[nDown[i]]) dst[nDown[i]] -= share;
dst[nRight[i]] -= d * 0.125; 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); const actK = Math.pow(0.5, dt / 1.4);
for (let i = 0; i < act.length; i++) act[i] *= actK; 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 scale = (0.22 + 0.7 * regime.energy) * dt;
const mask = this.rockMask;
for (const { well, env } of living) { for (const { well, env } of living) {
const add = well.rate * scale * env / well.peak; const add = well.rate * scale * env / well.peak;
const idx = well.kernel.idx; const idx = well.kernel.idx;
const w = well.kernel.w; const w = well.kernel.w;
for (let i = 0; i < idx.length; i++) { for (let i = 0; i < idx.length; i++) {
const cell = idx[i]; const cell = idx[i];
if (mask[cell]) continue;
res[cell] = Math.min(1.6, res[cell] + add * w[i]); res[cell] = Math.min(1.6, res[cell] + add * w[i]);
} }
} }
+4
View File
@@ -55,12 +55,16 @@ export function buildPalette(summary, rng) {
const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright); const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright);
const fog = hsl(hue + 0.5, 0.6, 0.075); 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 { return {
hue, hue,
sat, sat,
bg, bg,
fog, fog,
stone,
classes: [c0, c1, c2], classes: [c0, c1, c2],
bodies, bodies,
// Trails sit below the agents: the world is dimmer than what moves in it. // Trails sit below the agents: the world is dimmer than what moves in it.
+20 -8
View File
@@ -36,18 +36,18 @@ const lerp = (a, b, t) => a + (b - a) * t;
* speed / growth / deposit / split / spawn * speed / growth / deposit / split / spawn
*/ */
export const REGIMES = { 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 }, 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 }, 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 }, 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 }, 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 }, 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 }, 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. // 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 * 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, split: 0.85 + 0.35 * density,
spawn: 0.80 + 0.50 * beat, spawn: 0.80 + 0.50 * beat,
peace: 0.85 + 0.30 * tone, // tonal tracks settle easier 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), splash: strains.map((s) => SPLASH_BY_ROLE[s.role] ?? 0.5),
voiceStrain, 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)),
},
}; };
} }
+27 -10
View File
@@ -28,7 +28,8 @@ export const S_TIME = 8;
export const S_TOTAL = 9; export const S_TOTAL = 9;
export const S_WELL = 10; // nearest living well's pull export const S_WELL = 10; // nearest living well's pull
export const S_PEACE = 11; // section's peace operator (war ↔ truce) 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. // Gradient fields, two slots each (x, y): food, own strain, kin, foe, well.
export const G_FOOD = 0; export const G_FOOD = 0;
@@ -47,7 +48,9 @@ export const A_HIT = 4;
export const A_SPAWN = 5; export const A_SPAWN = 5;
export const A_DRIFT = 6; // gradual direction change following a gradient export const A_DRIFT = 6; // gradual direction change following a gradient
export const A_ACCELERATE = 7; // speed up / slow down 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 REGS = 6;
export const MAX_INSTR = 32; export const MAX_INSTR = 32;
@@ -89,23 +92,23 @@ class Asm {
const ROLES = { const ROLES = {
builder: { 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, hungryAt: 0.50, spawnW: 0.30,
}, },
grazer: { 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, hungryAt: 0.42, spawnW: 0.45,
}, },
scout: { 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, hungryAt: 0.62, spawnW: 0.60,
}, },
sentinel: { 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, hungryAt: 0.50, spawnW: 0.25, foeAt: 0.10,
}, },
breeder: { 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, hungryAt: 0.55, spawnW: 0.85, kinAt: 0.12,
}, },
}; };
@@ -189,14 +192,25 @@ export function compileStrains(summary, genome, rng) {
const fed = asm.label(); const fed = asm.label();
asm.jump(hungryPatch, fed); asm.jump(hungryPatch, fed);
if (role === 'builder') { if (role === 'builder') {
// The ink-dropper: inscribes, but never quite stops — a slow orbit // The mason: where rock lies underfoot it quarries (rock becomes
// around its ground keeps the colony's edge alive. // carried burden and, ground down, soil); where the ground is
asm.act(A_EXCRETE, 3, 1.35); // 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(4, G_SELF, 0, 0.4);
asm.grad(5, G_SELF, 1, 0.4); asm.grad(5, G_SELF, 1, 0.4);
asm.act(A_DRIFT, 4, 0.8); asm.act(A_DRIFT, 4, 0.8);
asm.act(A_SPAWN, 1, spec.spawnW); asm.act(A_SPAWN, 1, spec.spawnW);
asm.halt(); 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') { } else if (role === 'grazer') {
// Commutes: follows the food edge, leaves a road behind. // Commutes: follows the food edge, leaves a road behind.
asm.grad(4, G_FOOD, 0, 0.7); asm.grad(4, G_FOOD, 0, 0.7);
@@ -297,6 +311,7 @@ export function runProgram(memBuf, off, senses, grads, act, code) {
let steps = 0; let steps = 0;
const n = code.length; const n = code.length;
act[0] = 0; act[1] = 0; act[2] = 0; act[3] = 1; act[4] = 0; act[5] = 0; 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; const mem = memBuf;
while (steps++ < MAX_STEPS && pc < n) { while (steps++ < MAX_STEPS && pc < n) {
const ins = code[pc]; 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_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_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_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; default: break;
} }
break; break;
+153
View File
@@ -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)})`);
});