diff --git a/metabolism/DESIGN.md b/metabolism/DESIGN.md index 75d404a..5bddfee 100644 --- a/metabolism/DESIGN.md +++ b/metabolism/DESIGN.md @@ -171,25 +171,22 @@ district. The film shows specialisation *evolving* inside one species. **Allegiance — the body.** Every organism also belongs to a macro-organism — its **body** — a polity of mixed strains that builds, defends and expands as -one. Allegiance is a *field*: the population's composition per cell, advected -frozen-in with the organisms as they move. And it changes: - -- **conversion** — at a contested front the stronger body captures ground and - converts the locals; -- **defection** — when a body's presence collapses (its food gone, its ground - lost), its members flip to the neighbouring body; -- **fission** — a coherent splinter cut off from its body, blessed by a strong - onset, is founded as a new body with a new colour; -- **merger** — two bodies whose members fully intermix under a cooperative - regime become one. +one. Allegiance is a *field*: the population partitioned by body, advected +frozen-in with the organisms as they move. **Allegiance does not change +sides.** A body is founded once and persists; war kills, it does not convert; +there is no defection and no merger — territories stay legible because their +colours never homogenise. What keeps more than one body alive is +**colonisation**: a settlement on ground far from every body core, blessed by +a strong onset, may plant a NEW flag (a fresh colour) — rare, and it grows +the cast rather than reshuffling it. Bodies are few — the song opens 2–4, capped at ~8 so colour stays legible — and they are the drama. The film is the story of macro-organisms **fighting -and cooperating**: skirmish lines at fronts, truces in consonant sections, -symbiosis where one body's waste is another's food. The song sets the terms -of diplomacy: consonant, harmonically locked material breeds symbiosis; -dissonant, decorrelated material breeds rivalry; drops are expansion wars; -breakdowns are truces and mutual building. +and holding ground**: skirmish lines at fronts where the loser bleeds, truces +in consonant sections, symbiosis where one body's waste is another's food. +The song sets the terms of diplomacy: consonant, harmonically locked material +breeds peace; dissonant, decorrelated material breeds rivalry; drops are +expansion wars; breakdowns are truces and mutual building. A species name like "fast tonal weaver" falls out of the genome, so the report can talk about the world and not the track. @@ -289,24 +286,24 @@ world behind the `Run` is being rebuilt.* per-song rule program: castes as rule entries (mutualism, social floors), interaction rules, per-section operator regimes (structural section changes), the event→response mapping, and a shared carrying capacity. -- **M2.7 — one species, many minds** *(designed, not yet built — see - EVOLUTION.md)*: tribes/castes are dissolved into a single population field - with two live properties — a **strain mix** of song-compiled LittleVM - personalities evolving by local selection, and an **allegiance field** - whose macro-organisms ("bodies") convert, defect, fission and merge, - fighting or cooperating as the song's harmony and sections dictate — on top - of the living well timeline. +- **M2.7 — one species, many minds** ✔ built: tribes/castes dissolved into a + single population field with two live properties — a **strain mix** of + song-compiled LittleVM personalities evolving by local selection, and an + **allegiance field** whose macro-organisms ("bodies") hold territory, wage + war (which kills, never converts) and colonise virgin ground — on top of + 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. - **M3 — lens** the camera travels (track / reveal / establish) and reserves cuts for musical punctuation. - **M4 — film** mux frames + PCM to video. ## Current state, honestly -The world is alive and the songs diverge on the metrics, but it is not yet a -*film*: the renderer is a software-framebuffer proof, the reference tracks are -synthetic test signals, the ground the population stands on is still furniture -(four permanent springs), and the population itself is still three fixed -castes instead of one evolving mix of minds. M2.7 (EVOLUTION.md) fixes both: -wells that are born, drift and die; strains — song-compiled LittleVM -personalities — whose mix adapts by local selection; and allegiances — -macro-organisms that fight, cooperate, and change membership over time. \ No newline at end of file +The world is alive, the songs diverge on the metrics, and the population is +now one species of many minds: strains compiled by the song, bodies that hold +territory and war over living wells. It is not yet a *film*: the reference +tracks are synthetic test signals, the lens still prefers the biggest knot to +a traveling shot (M3), and muxing is waiting (M4). The allegiance layer is +deliberately rigid — bodies never switch sides — so territories read; the +drama currently comes from war, colonisation and the wells' own lives. \ No newline at end of file diff --git a/metabolism/EVOLUTION.md b/metabolism/EVOLUTION.md index d511f55..96ec290 100644 --- a/metabolism/EVOLUTION.md +++ b/metabolism/EVOLUTION.md @@ -129,62 +129,53 @@ within-seed ≈ 0.02–0.04, ~4–7×) even though the films look different. Bro the instruction grammar (more branch shapes, richer memory, more action archetypes) is the natural next step for this layer. -### M2.7 — one species, many minds: strains & allegiances (design agreed; not yet built) +### M2.7 — one species, many minds: strains & allegiances ✔ -A further correction, agreed before building anything on M2.6: **drop tribes -entirely.** Not three castes, not k tribes of fixed membership. The picture is -*one population of specialised microorganisms building a bigger organism* — -and then several such macro-organisms sharing a map, sometimes fighting, -sometimes cooperating. Membership is a property of each organism, and it can -change over time. +Built — with one correction agreed mid-build, after watching the first films: -**Wells keep the correction from the previous draft** — small, weak, many -(8–16), born where a pitch class enters and holds, drifting with its harmony, -dying when it leaves, all on a precompiled deterministic timeline — because -wells are where bodies collide: everyone needs them, so a well is the natural -front, its birth a land-rush, its death a retreat. +> **Allegiance does not change sides.** The original draft had fronts +> converting, collapsed bodies leaking their members, splinters seceding and +> friendly bodies merging. On screen that read as one thing: every colour +> blurring into every other within a minute — ugly, and it erased the very +> polities the mechanic was meant to create. So the fluid politics were +> removed: bodies are founded once and persist; war kills, it does not +> convert; there is no defection and no merger. What keeps the cast plural is +> **colonisation** — a blessed settlement on virgin ground, far from every +> body core, plants a new flag. And the renderer now paints each cell in its +> DOMINANT body's colour, so territories read as territories. -**The population is one density field with two live properties:** +What was built: -- **strain mix (personality).** The song decides how many minds it needs — - *m* strains from the number of independent voices the mix resolves into, - bounded by arrangement depth, jittered by the seed — and compiles a - **LittleVM program per strain** from the grammar: senses (food, density, - trail, law, hunger, rim, well proximity and liveliness, body presence), - registers as memory, real branching, actions (move, graze, excrete, hit, - spawn, follow, patrol). Density is partitioned by strain; each strain - advects by its own program. And the mix **evolves by local selection**: - strains that eat and grow where they stand propagate, proportions shift - district by district — specialisation is visible and develops over the film. -- **allegiance (the body).** A composition field over bodies (the song opens - 2–4; a cap of ~8 keeps colour legible), advected frozen-in with the - population flux. Same body = **cooperation**: shared trails, reinforcement, - complementary strains building one structure. Different bodies = **contest** - where presences overlap: hit-actions spend density to push the other body's - ground and convert it, scaled by the song's harmony — consonant, locked - voices breed truce and symbiosis; dissonant, decorrelated voices breed - skirmish and siege. Allegiance changes by rule, never by fiat: - - **conversion** — a won front converts the locals it overruns; - - **defection** — a body whose presence collapses (starved, outflanked) - releases its members to the neighbouring body; - - **fission** — a coherent splinter cut off from its body, hit by a strong - onset, founds a new body with a new colour; - - **merger** — two bodies whose members fully intermix under a cooperative - regime become one. +- **strains** (`src/song/strains.js`) — the song decides how many minds it + needs (2–5, from onset density, an independent spectral voice, texture + noisiness, seed jitter) and compiles a LittleVM program per strain from + motif roles: builder, grazer, scout, sentinel, breeder. Senses include the + body's presence (KIN/FOE), the living wells, and the section's PEACE — a + program can branch on war or truce. The world runs local selection: + strains that thrive where they stand propagate. +- **living wells** (`src/sim/wells.js`) — a precompiled deterministic + timeline of small, weak, numerous wells (4+ at open, ≤12 alive): born where + a pitch class enters and holds, drifting along its harmony's compass, + starving when it leaves. A birth is a land-rush; a death a retreat. +- **bodies & diplomacy** — allegiance is a concentration field advected + frozen-in with the population flux (Σ bodies = density exactly, a test). + Diplomacy = the track's consonance bent by the section's peace operator; + hostile fronts bleed the loser (casualties, never conversion). Colonisation + plants new bodies on blessed virgin ground. +- **a food-limited economy** — the old world was accidentally + cap-limited: the gate floor let life grow on bare ground, so the dish + filled to a uniform saturated carpet and stopped moving. Now the gate is + nearly shut without food and starvation dissolves colonies off bare + ground: the population tracks its wells, blooms on them, and must keep + moving to keep eating. -**The song sets the diplomacy.** Consonance and the section regimes map to -war and peace: drops are expansion wars, breakdowns are truces and mutual -building, an outro is a settling of borders. The body-to-body relations come -from the music, not a constant. - -*Exit: two songs with the same pulse but different arrangement depth compile -different strain counts and different diplomacies; at least one allegiance -change (conversion, defection, fission or merger) is legible on screen per -film; strain proportions measurably drift across the film (local selection -actually runs); a well's birth, drift and death are each legible; the world's -typed-array layers, trail tints and palette generalise from 3 fixed castes to -the strain/body fields; the prepared-artifact path still rebuilds -bit-identical timelines, programs and opening state.* +*Outcome:* 39 tests green, including the allegiance invariant (no negatives, +composition = population), war-kills-not-converts (a war world ends poorer +than its truce twin, with each body's mass still in its own heartland), +colonisation, strain-mix drift, well determinism/drift/land-rush. Realtime +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). ### M3 — the lens: travel, don't cut Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a diff --git a/metabolism/src/camera.js b/metabolism/src/camera.js index 2741ca0..3883cab 100644 --- a/metabolism/src/camera.js +++ b/metabolism/src/camera.js @@ -96,10 +96,12 @@ export class Camera { // map — every culture and the roads between them — is in frame, and // only dip toward a knot on the rare close beat. const coverage = this.activityCoverage(); - let zoom = 0.92 + (1 - coverage) * 0.55; - if (this.shot % 3 === 2) zoom *= 0.86; - else if (this.shot % 3 === 1) zoom *= 1.12; - zoom = clamp(zoom, 0.62, 1.5); + let zoom = 0.92 + (1 - coverage) * 0.35; + if (this.shot % 3 === 2) zoom *= 0.88; + else if (this.shot % 3 === 1) zoom *= 1.08; + // The world is a map, not a Petri dish: stay high enough that + // territories and their fronts read. + zoom = clamp(zoom, 0.62, 1.08); const focus = this.pickFocus(zoom); this.target = { x: focus.x, y: focus.y }; diff --git a/metabolism/src/metrics.js b/metabolism/src/metrics.js index 3398de7..ad77c94 100644 --- a/metabolism/src/metrics.js +++ b/metabolism/src/metrics.js @@ -199,8 +199,8 @@ export function fingerprint(world) { const cell = F / g; const pop = new Float32Array(g * g); const trail = new Float32Array(g * g); - const balance = [0, 0, 0]; - for (let c = 0; c < 3; c++) { + const balance = new Array(world.pop.length).fill(0); + for (let c = 0; c < world.pop.length; c++) { const p = world.pop[c]; for (let fy = 0; fy < g; fy++) { for (let fx = 0; fx < g; fx++) { @@ -220,7 +220,7 @@ export function fingerprint(world) { for (let i = 0; i < pp.length; i++) tot += pp[i]; balance[c] = tot; } - for (let c = 0; c < 3; c++) { + for (let c = 0; c < world.trail.length; c++) { const t = world.trail[c]; for (let fy = 0; fy < g; fy++) { for (let fx = 0; fx < g; fx++) { @@ -246,15 +246,19 @@ export function fingerprint(world) { maxP = Math.max(maxP, pop[i]); if (trail[i] > 1.5) trailFill++; } - const total = Math.max(1, balance[0] + balance[1] + balance[2]); + const total = Math.max(1, balance.reduce((a, v) => a + v, 0)); // Cluster: compared to what a uniform full plate would hold. const uniform = total / n; cluster = uniform > 0 ? Math.min(1, maxP / (uniform * 18)) : 0; + // The seed rotates which roles exist, permuting the strain shares — so + // the mix enters the fingerprint SORTED: the shape of the specialisation, + // not the accident of the casting order. + const mix = balance.map((v) => v / total).sort((x, y) => y - x); return { occupancy: Number((occupancy / n).toFixed(3)), cluster: Number(cluster.toFixed(2)), trailFill: Number((trailFill / n).toFixed(3)), - classMix: balance.map((v) => Number((v / total).toFixed(2))), + classMix: mix.map((v) => Number(v.toFixed(2))), }; } @@ -263,6 +267,7 @@ export function divergence(a, b) { let d = Math.abs(a.occupancy - b.occupancy) + Math.abs(a.cluster - b.cluster) + Math.abs(a.trailFill - b.trailFill); - for (let i = 0; i < 3; i++) d += Math.abs((a.classMix?.[i] ?? 0) - (b.classMix?.[i] ?? 0)); + const n = Math.max(a.classMix?.length ?? 0, b.classMix?.length ?? 0); + for (let i = 0; i < n; i++) d += Math.abs((a.classMix?.[i] ?? 0) - (b.classMix?.[i] ?? 0)); return Number(d.toFixed(3)); } \ No newline at end of file diff --git a/metabolism/src/render/bitmap.js b/metabolism/src/render/bitmap.js index 86ac820..adfe479 100644 --- a/metabolism/src/render/bitmap.js +++ b/metabolism/src/render/bitmap.js @@ -204,19 +204,18 @@ export function composeTrail(world, palette, exposure = 2.2) { const F = world.field; const n = F * F; const sharp = new Float32Array(n * 3); - const t0 = world.trail[0]; - const t1 = world.trail[1]; - const t2 = world.trail[2]; - const c0 = palette.trails[0]; - const c1 = palette.trails[1]; - const c2 = palette.trails[2]; - for (let i = 0; i < n; i++) { - const a = t0[i]; - const b = t1[i]; - const c = t2[i]; - sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0]; - sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1]; - sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2]; + const trails = world.trail; + const cols = palette.bodyTrails ?? palette.bodies ?? palette.trails; + for (let b = 0; b < trails.length; b++) { + const t = trails[b]; + const c = cols[b % cols.length]; + for (let i = 0; i < n; i++) { + const v = t[i]; + if (v === 0) continue; + sharp[i * 3] += v * c[0]; + sharp[i * 3 + 1] += v * c[1]; + sharp[i * 3 + 2] += v * c[2]; + } } const wide = boxBlur(sharp, F, 2); const tight = boxBlur(sharp, F, 1); diff --git a/metabolism/src/render/view.js b/metabolism/src/render/view.js index 76958b3..68731d0 100644 --- a/metabolism/src/render/view.js +++ b/metabolism/src/render/view.js @@ -15,8 +15,8 @@ import { Bitmap } from './bitmap.js'; const PER_SPECKS = 2.2; const MAX_SPECKS = 60000; // Density glow: how fast a cell's population turns into brightness. -const DENSITY_K = 0.16; -const DENSITY_EXPOSURE = 3.2; +const DENSITY_K = 0.11; +const DENSITY_EXPOSURE = 1.25; export class Renderer { constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) { @@ -52,22 +52,27 @@ export class Renderer { if (this.F !== F) this.allocate(F); const n = F * F; const sharp = this.sharp; - const t0 = world.trail[0]; - const t1 = world.trail[1]; - const t2 = world.trail[2]; - const c0 = palette.trails[0]; - const c1 = palette.trails[1]; - const c2 = palette.trails[2]; + const trails = world.trail; + const cols = palette.bodyTrails ?? palette.bodies ?? palette.trails; const fog = palette.fog; const res = world.resource; + sharp.fill(0); + for (let b = 0; b < trails.length; b++) { + const t = trails[b]; + const c = cols[b % cols.length]; + for (let i = 0; i < n; i++) { + const v = t[i]; + if (v === 0) continue; + sharp[i * 3] += v * c[0]; + sharp[i * 3 + 1] += v * c[1]; + sharp[i * 3 + 2] += v * c[2]; + } + } for (let i = 0; i < n; i++) { - const a = t0[i]; - const b = t1[i]; - const c = t2[i]; const f = res[i] * 0.09; - sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0] + f * fog[0]; - sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1] + f * fog[1]; - sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2] + f * fog[2]; + sharp[i * 3] += f * fog[0]; + sharp[i * 3 + 1] += f * fog[1]; + sharp[i * 3 + 2] += f * fog[2]; } boxBlur(sharp, this.tmp, this.tight, F, 1); boxBlur(sharp, this.tmp, this.wide, F, 3); @@ -84,24 +89,42 @@ export class Renderer { return out; } - /** The living density: each caste's population as an additive glow. */ + /** The living density: the population glow, coloured by ALLEGIANCE. */ density(world, palette) { const F = world.field; if (this.F !== F) this.allocate(F); const n = F * F; - const pop = world.pop; - const col = palette.classes; + const alleg = world.alleg; + const dens = world.density; + const col = palette.bodies ?? palette.classes; const out = this.densityRGBA; for (let i = 0; i < n; i++) { - const a0 = 1 - Math.exp(-pop[0][i] * DENSITY_K); - const a1 = 1 - Math.exp(-pop[1][i] * DENSITY_K); - const a2 = 1 - Math.exp(-pop[2][i] * DENSITY_K); - const r = (a0 * col[0][0] + a1 * col[1][0] + a2 * col[2][0]) * DENSITY_EXPOSURE; - const g = (a0 * col[0][1] + a1 * col[1][1] + a2 * col[2][1]) * DENSITY_EXPOSURE; - const b = (a0 * col[0][2] + a1 * col[1][2] + a2 * col[2][2]) * DENSITY_EXPOSURE; - out[i * 4] = Math.round(255 * (1 - Math.exp(-r))); - out[i * 4 + 1] = Math.round(255 * (1 - Math.exp(-g))); - out[i * 4 + 2] = Math.round(255 * (1 - Math.exp(-b))); + const d = dens[i]; + if (d < 1e-6) { + out[i * 4] = 0; + out[i * 4 + 1] = 0; + out[i * 4 + 2] = 0; + out[i * 4 + 3] = 255; + continue; + } + const a = 1 - Math.exp(-d * DENSITY_K); + // The DOMINANT body owns the cell's colour — territories must + // read as territories; blending every body into a mud is how the + // map disappears. Loyalty shades the colour slightly. + let dom = 0; + let domV = 0; + let sum = 0; + for (let c = 0; c < alleg.length; c++) { + const w = alleg[c][i]; + if (w > domV) { domV = w; dom = c; } + sum += w; + } + const share = sum > 1e-6 ? domV / sum : 1; + const cc = col[dom % col.length]; + const loyal = 0.72 + 0.28 * share; + out[i * 4] = Math.round(255 * (1 - Math.exp(-cc[0] * a * DENSITY_EXPOSURE * loyal))); + out[i * 4 + 1] = Math.round(255 * (1 - Math.exp(-cc[1] * a * DENSITY_EXPOSURE * loyal))); + out[i * 4 + 2] = Math.round(255 * (1 - Math.exp(-cc[2] * a * DENSITY_EXPOSURE * loyal))); out[i * 4 + 3] = 255; } return out; @@ -133,16 +156,13 @@ export class Renderer { const scale = this.w * cam.zoom; const halfW = this.w / 2; const halfH = this.h / 2; - const col = palette.classes; + const col = palette.bodies ?? palette.classes; + const alleg = world.alleg; const d = bmp.data; let budget = MAX_SPECKS; - for (let c = 0; c < 3; c++) { - const p = world.pop[c]; - const cc = col[c]; - const r255 = cc[0] * 255; - const g255 = cc[1] * 255; - const b255 = cc[2] * 255; + const p = world.density; + { const cx = cam.cx; const cy = cam.cy; for (let y = 0; y < F && budget > 0; y++) { @@ -150,16 +170,34 @@ export class Renderer { const sy = (wy - cy) * scale + halfH; if (sy < -2 || sy > this.h + 2) continue; for (let x = 0; x < F && budget > 0; x++) { - const v = p[y * F + x]; + const i = y * F + x; + const v = p[i]; if (v < 1.2) continue; const wx = (x + 0.5) / F; const sx = (wx - cx) * scale + halfW; if (sx < -2 || sx > this.w + 2) continue; + // The crowd wears the colours of its bodies: the dominant + // allegiance here picks the tint, the mix shades it. + let dom = 0; + let domV = 0; + let mixV = 0; + for (let c = 0; c < alleg.length; c++) { + const a = alleg[c][i]; + if (a > domV) { domV = a; dom = c; } + mixV += a * a; + } + const cc = col[dom % col.length]; + const r255 = cc[0] * 255; + const g255 = cc[1] * 255; + const b255 = cc[2] * 255; const count = Math.min(budget, Math.min(14, Math.round(v * PER_SPECKS + 1))); const bright = 0.5 + 0.5 * Math.min(1, v / 8); - const a = 0.30 + 0.50 * bright; + // A mixed crowd is dimmer and softer than a loyal one — + // contested ground reads as less certain colour. + const loyalty = mixV > 1e-6 ? Math.min(1, domV * domV / mixV) : 1; + const a = (0.30 + 0.50 * bright) * (0.55 + 0.45 * loyalty); for (let k = 0; k < count; k++) { - const seed = (x * 374761393 + y * 668265263 + c * 1274126177 + k * 974711) >>> 0; + const seed = (i * 374761393 + k * 974711) >>> 0; const s2 = (Math.imul(seed ^ (seed >>> 13), 1274126177) ^ 2246822519) >>> 0; const px = sx + ((s2 >>> 24) / 255 - 0.5) * 1.8; const py = sy + (((s2 >>> 16) & 255) / 255 - 0.5) * 1.8; diff --git a/metabolism/src/sim/wells.js b/metabolism/src/sim/wells.js index 1cd1541..abd6693 100644 --- a/metabolism/src/sim/wells.js +++ b/metabolism/src/sim/wells.js @@ -1,89 +1,254 @@ -// Fertile ground. +// Fertile ground — now with a life of its own (EVOLUTION.md M2.7). // -// The uniform ambient floor made every frame a fog: food everywhere is the same -// as geography nowhere. Wells fix that. They are placed by the track's own -// harmony — one well per strong pitch class, on a ring — so the chord is the -// map. The mix decides which wells are rich: bass-heavy tracks feed low -// classes, bright ones feed the upper ring. +// The old wells were furniture: 4–5 permanent rich springs that set the map +// in the first ten seconds and never moved. These wells are small, weak and +// many (8–16 active), and they are *characters* in the film: // -// The wells are permanent. That is the point: a place the audience can learn, -// return to, and see change. Events still land food elsewhere on the song's -// grid; the wells are the slow layer underneath. - -import { TAU } from '../song/palette.js'; +// birth a pitch class enters the music and HOLDS → a well sprouts where +// its events already landed (a land-rush); +// drift a well wanders with its harmony — it leans along the smoothed +// dominant-pitch path and breathes on slow per-well orbits; +// death a pitch class that leaves the song starves its well: recharge +// fades, the food depletes, the colony must abandon the ground. +// +// The whole lifecycle is compiled ONCE, here, from the analysis, into a +// deterministic timeline (birth frame, death frame, dense path). The sim only +// plays it back — no mid-sim decisions, no clocks, no randomness. +const TAU = Math.PI * 2; const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); -/** Circular distance between pitch classes, in semitones. */ -function pcDistance(a, b) { - const d = Math.abs(a - b) % 12; - return Math.min(d, 12 - d); +const MAX_ACTIVE = 12; // alive at once — many, but never a carpet +const MIN_OPENING = 4; // the world always wakes with at least this many +const PATH_STEP = 2; // path sampled every 2 frames +const BIRTH_HOLD = 1.6; // seconds a pitch class must hold to found a well +const DEATH_HOLD = 6.0; // seconds of silence that starve one +const REBIRTH_GAP = 3.0; // seconds before the same pitch class can re-found + +function pcOfFrame(analysis, f) { + const hz = analysis.dominantHz[f]; + if (!hz || hz <= 0) return -1; + const semis = Math.round(12 * Math.log2(hz / 440)); + return ((semis % 12) + 12 + 9) % 12; } -function pickPitchClasses(chroma) { - const order = Array.from({ length: 12 }, (_, i) => i) - .sort((a, b) => (chroma[b] ?? 0) - (chroma[a] ?? 0) || a - b); - const picked = []; - for (const pc of order) { - if (picked.every((p) => pcDistance(p, pc) >= 2)) picked.push(pc); - if (picked.length >= 5) break; +/** Circular smoothing: mean unit vector, then atan2 — no angle wrap artefacts. */ +function smoothAngle(angleAt, frames, half) { + const cos = new Float32Array(frames); + const sin = new Float32Array(frames); + for (let f = 0; f < frames; f++) { + const a = angleAt(f); + cos[f] = a >= 0 ? Math.cos(a) : 0; + sin[f] = a >= 0 ? Math.sin(a) : 0; } - // A two-note track still needs a world with more than two rooms. Fill the - // largest gaps with quiet wells so composition never collapses to a line. - while (picked.length < 4) { - const sorted = [...picked].sort((a, b) => a - b); - let gapAt = 0; - let gap = -1; - for (let i = 0; i < sorted.length; i++) { - const a = sorted[i]; - const b = sorted[(i + 1) % sorted.length]; - const d = ((b - a + 12) % 12) || 12; - if (d > gap) { - gap = d; - gapAt = a + Math.floor(d / 2); - } + const out = new Float32Array(frames); + for (let f = 0; f < frames; f++) { + let sx = 0; + let sy = 0; + let n = 0; + for (let d = -half; d <= half; d++) { + const i = clamp(f + d, 0, frames - 1); + sx += cos[i]; + sy += sin[i]; + n++; } - picked.push(((gapAt % 12) + 12) % 12); + out[f] = (sx * sx + sy * sy) > 1e-9 ? Math.atan2(sy / n, sx / n) : -1; } - return picked.slice(0, 5); + return out; +} + +function smoothSeries(src, half) { + const n = src.length; + const out = new Float32Array(n); + for (let f = 0; f < n; f++) { + let s = 0; + for (let d = -half; d <= half; d++) s += src[clamp(f + d, 0, n - 1)]; + out[f] = s / (2 * half + 1); + } + return out; } /** - * @param {object} summary analysis.summary - * @param {number} field grid size - * @param {number} [sigmaScale] tightens the fertile patches (macro worlds want - * food localized so colonies deplete barriers between them) - * @returns {Array<{x:number,y:number,r:number,rate:number,pc:number,idx:Int32Array,w:Float32Array}>} + * Compile the well timeline for a song. + * @param {object} analysis the analysis table (fps, frames, dominantHz, flatness, onsets, summary) + * @param {object} rng seeded fork + * @returns {{ wells: Array, maxActive: number }} + * well: { id, pc, born, died, pathStart, pathStep, px, py, rate, sigma, peak } + * px/py are sampled every `pathStep` frames from `pathStart`; died is + * Number.POSITIVE_INFINITY while the well lives. */ -export function buildWells(summary, field, sigmaScale = 1) { - const chroma = summary.chroma || new Array(12).fill(0.5); - const pcs = pickPitchClasses(chroma); - const balance = summary.bandBalance || [0.33, 0.33, 0.33]; +export function compileWells(analysis, rng) { + const fps = analysis.fps; + const frames = analysis.frames; + const chroma = analysis.summary.chroma || new Array(12).fill(0.4); + const balance = analysis.summary.bandBalance || [0.33, 0.33, 0.33]; - return pcs.map((pc, i) => { + // ---- per-frame pitch class, and its smoothed compass angle ------------- + const pcAt = new Int8Array(frames).fill(-1); + for (let f = 0; f < frames; f++) pcAt[f] = pcOfFrame(analysis, f); + const domAngle = smoothAngle((f) => (pcAt[f] >= 0 ? (pcAt[f] / 12) * TAU : -1), frames, Math.round(fps * 1.5)); + + // ---- presence per pitch class: smoothed onset energy -------------------- + const presence = Array.from({ length: 12 }, () => new Float32Array(frames)); + for (const o of analysis.onsets.all) { + const f = o.frame; + if (f < 0 || f >= frames) continue; + const pc = pcAt[f]; + if (pc >= 0) presence[pc][f] += o.strength; + } + const half = Math.max(2, Math.round(fps * 0.6)); + for (let pc = 0; pc < 12; pc++) presence[pc] = smoothSeries(presence[pc], half); + + // Per-pc thresholds: a pc is "holding" when it clearly out-sings its + // own baseline (mean + one deviation, with an absolute floor so + // near-silence never counts). + const mean = new Float32Array(12); + const std = new Float32Array(12); + const thr = new Float32Array(12); + for (let pc = 0; pc < 12; pc++) { + let s = 0; + for (let f = 0; f < frames; f++) s += presence[pc][f]; + mean[pc] = s / Math.max(1, frames); + let v = 0; + for (let f = 0; f < frames; f++) { const d = presence[pc][f] - mean[pc]; v += d * d; } + std[pc] = Math.sqrt(v / Math.max(1, frames)); + thr[pc] = Math.max(0.05, mean[pc] + std[pc] * 0.9); + } + + // ---- the lifecycle walk -------------------------------------------------- + const wells = []; + const alive = new Array(12).fill(null); // well per pc, or null + const lastDeath = new Float32Array(12).fill(-1e9); + let nextId = 0; + + const birth = (pc, f, forced) => { const value = clamp(chroma[pc] ?? 0.4, 0.05, 1); - const angle = (pc / 12) * TAU + 0.10 * Math.sin(i * 2.3); - // Two rings so the world is not a necklace: alternate radii. - const ring = i % 2 === 0 ? 0.28 : 0.36; - const radius = ring + 0.03 * Math.sin(i * 1.7); - const sigma = (0.050 + 0.040 * value) * sigmaScale; - const rate = (0.38 + 1.15 * value) * (0.7 + 0.6 * balance[i % 3]); - - const cells = buildKernel(field, 0.5 + Math.cos(angle) * radius, 0.5 + Math.sin(angle) * radius, sigma, field); - return { + const angle = (pc / 12) * TAU + rng.range(-0.10, 0.10); + const ring = (wells.length % 2 === 0 ? 0.28 : 0.36) + rng.range(-0.02, 0.02); + // Small and weak: tight kernel, modest recharge — the same total + // fertility budget as the old permanent springs, spread thin. + const rate = (0.55 + 1.45 * value) * (0.7 + 0.6 * balance[wells.length % 3]) * (forced ? 1.2 : 1); + const sigma = 0.032 + 0.016 * value; + const w = { + id: nextId++, pc, - x: 0.5 + Math.cos(angle) * radius, - y: 0.5 + Math.sin(angle) * radius, - r: sigma, + born: f, + died: Number.POSITIVE_INFINITY, + pathStart: f, + pathStep: PATH_STEP, + px: null, + py: null, rate, - idx: cells.idx, - w: cells.w, + sigma, + peak: Math.max(1e-6, value), }; - }); + // ---- the drift path: sampled over the well's whole life. It leans + // along the song's smoothed dominant-pitch compass (its harmony moves, + // it follows) and breathes on its own slow orbit. + const end = Number.isFinite(w.died) ? w.died : frames - 1; + const len = Math.max(1, Math.ceil((end - f + 1) / PATH_STEP)); + const px = new Float32Array(len); + const py = new Float32Array(len); + const w1 = (TAU / (30 + 18 * rng.next())) * (rng.next() < 0.5 ? 1 : -1); + const w2 = TAU / (18 + 14 * rng.next()); + const phi1 = rng.range(0, TAU); + const phi2 = rng.range(0, TAU); + const a1 = 0.16 + 0.14 * rng.next(); + const a2 = 0.02 + 0.02 * rng.next(); + const chase = 0.22 + 0.20 * rng.next(); + for (let k = 0; k < len; k++) { + const t = ((f + k * PATH_STEP) / fps); + let a = angle + a1 * Math.sin(t * w1 + phi1); + const da = domAngle[f + Math.min(frames - 1, k * PATH_STEP)]; + if (da >= 0) { + let d = ((da - (pc / 12) * TAU + Math.PI) % TAU + TAU) % TAU - Math.PI; + a += chase * clamp(d, -0.7, 0.7); + } + const r = ring + a2 * Math.sin(t * w2 + phi2); + px[k] = 0.5 + Math.cos(a) * r; + py[k] = 0.5 + Math.sin(a) * r; + } + w.px = px; + w.py = py; + wells.push(w); + alive[pc] = w; + return w; + }; + + const kill = (w, f) => { + w.died = f; + alive[w.pc] = null; + lastDeath[w.pc] = f; + }; + + const holdFrames = Math.round(BIRTH_HOLD * fps); + const deathFrames = Math.round(DEATH_HOLD * fps); + const rebirthFrames = Math.round(REBIRTH_GAP * fps); + + // The world wakes with its oldest inhabitants: the strongest pitch + // classes hold wells from before frame 0 (the genesis matures under + // them, and they are already at full strength when the film opens). + const openingOrder = Array.from({ length: 12 }, (_, pc) => pc) + .sort((a, b) => (chroma[b] ?? 0) - (chroma[a] ?? 0) || a - b); + const rampBack = Math.round(1.3 * fps); + for (const pc of openingOrder.slice(0, MIN_OPENING)) birth(pc, -rampBack, true); + + for (let f = 0; f < frames; f++) { + // deaths first — a pc leaving the song starves its well + for (let pc = 0; pc < 12; pc++) { + const w = alive[pc]; + if (!w || f <= w.born + deathFrames) continue; + let quiet = true; + for (let d = 0; d < deathFrames; d++) { + if (presence[pc][f - d] >= thr[pc]) { quiet = false; break; } + } + if (quiet) kill(w, f); + } + // then births — a pc entering and holding founds a well + const active = wells.reduce((n, w) => n + (f >= w.born && f < w.died ? 1 : 0), 0); + if (active >= MAX_ACTIVE) continue; + for (let pc = 0; pc < 12; pc++) { + if (alive[pc] || f - lastDeath[pc] < rebirthFrames) continue; + let held = true; + for (let d = 0; d < holdFrames; d++) { + if (presence[pc][f - d] < thr[pc]) { held = false; break; } + } + if (held) birth(pc, f, false); + } + } + + // Close any well that outlived the track. + for (const w of wells) { + if (!Number.isFinite(w.died)) w.died = frames - 1; + // Re-trim its path to the real life span. + const len = Math.max(1, Math.ceil((w.died - w.born + 1) / PATH_STEP)); + if (w.px.length > len) { + w.px = w.px.slice(0, len); + w.py = w.py.slice(0, len); + } + } + + return { wells, maxActive: MAX_ACTIVE }; } -function buildKernel(F, x, y, sigma, scale) { - const radiusPx = Math.ceil(sigma * scale * 2.6); +/** Position of a well at a frame (clamped to its life). */ +export function wellPos(w, frame) { + const k = clamp(Math.round((frame - w.pathStart) / w.pathStep), 0, w.px.length - 1); + return [w.px[k], w.py[k]]; +} + +/** Recharge envelope: ramps in over ~1.2 s, fades out over ~1.5 s. */ +export function wellEnv(w, frame, fps) { + if (frame < w.born || frame > w.died) return 0; + if (frame === w.born) return 0.35; // the birth frame already feeds + const up = Math.min(1, (frame - w.born) / (1.2 * fps)); + const down = Math.min(1, (w.died - frame) / (1.5 * fps)); + return up * down; +} + +/** A normalised Gaussian kernel over the field grid (the recharge footprint). */ +export function buildKernel(F, x, y, sigma) { + const radiusPx = Math.ceil(sigma * F * 2.6); const cx = Math.round(x * (F - 1)); const cy = Math.round(y * (F - 1)); const sig = Math.max(1.2, sigma * F); diff --git a/metabolism/src/sim/world.js b/metabolism/src/sim/world.js index 1f87191..668f110 100644 --- a/metabolism/src/sim/world.js +++ b/metabolism/src/sim/world.js @@ -1,43 +1,56 @@ -// The macro ecology (EVOLUTION.md M1). +// The macro ecology (EVOLUTION.md M1, retargeted by M2.7). // -// Replaces the ~2 600-entity sim with *population fields*: every cell holds a -// density per caste, stepped several times per rendered frame so local -// interactions propagate into coherent macro patterns. "Billions of little -// guys" is a density, rendered as texture — not entities, which would cap out -// at a few thousand. +// One population, many minds, several bodies. The world is a single +// population field whose density is partitioned two ways: // -// The front-end the entity world used is unchanged: events land food, wells -// from the chord recharge slowly, the section's law switches the rules at its -// boundary, palette comes from the key. The invariants survive: +// strains personality — the song's compiled LittleVM programs. Each +// strain advects by its own program's decisions; the mix of +// strains evolves by local selection (what eats and grows +// where it stands, propagates there). +// allegiance membership — which macro-organism ("body") the organisms in +// a cell belong to. Allegiance is a composition field advected +// frozen-in with the population flux, and it CHANGES: fronts +// convert, collapsing bodies leak their members to neighbours, +// cut-off splinters found new bodies, intermixed friends merge. +// +// Wells are alive too: a precompiled timeline plays births, drift and deaths +// of small, weak, numerous fertile places — the natural fronts between bodies. +// +// The front-end is unchanged: events land food, the section's law switches +// the rules at its boundary, palette comes from the key. The invariants: // // PERSISTENCE trail fields accumulate the geography for tens of seconds. // CAUSALITY a hit still lands food, a ring and a local bloom exactly -// where the track put it. +// where the track put it; a well's birth and death are the +// track's own harmony. // CONSEQUENCE every substep is a pure update of the previous one. // DETERMINISM no Math.random, no wall clock, no per-substep RNG: the static // jitter noise field is generated once from the seeded Rng and // the substep order is canonical. -// -// Performance: forces are integrated on a coarse "force grid" (2×2 block sums), -// then sampled down onto the fine field for advection. That both cuts the work -// and gives each cell long-range coupling to its neighbourhood — the macro -// coherence the film needs. The whole hot path uses L1 directions, so there -// are no square roots per substep. import { Rng } from '../rng.js'; -import { buildWells } from './wells.js'; -import { runTribe, SENSE_COUNT, ACTION_COUNT, REGS } from '../song/tribe.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, + G_FOOD, G_SELF, G_KIN, G_FOE, G_WELL, +} from '../song/strains.js'; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); export const FIELD = 128; export const RENDER_FIELD = 256; // speck/texture resolution at render (2× up) export const SUBSTEPS = 5; // sim steps per rendered frame (the old world: 1) +export const MAX_BODIES = 8; // Ink per individual per second, world units (tuned against a ~15 s half-life // so a full cell writes a visible line in seconds). const DEPOSIT_SCALE = 0.012; +// Politics cadence (frames at the sim's fps). Allegiance never changes sides: +// bodies are founded once and persist; war kills, it does not convert. +const COLONIZE_EVERY = 60; // ~2 s: a blessed frontier settlement may found + export class World { constructor(song, { fps = 30, field = FIELD, seed = song.seed, substeps = SUBSTEPS, genesis } = {}) { this.song = song; @@ -48,8 +61,10 @@ export class World { this.substeps = substeps; this.genome = song.genome; this.program = song.program; - this.tribes = song.tribes; + this.strains = song.strains; + this.m = this.strains.length; this.palette = song.palette; + this.wellTimeline = song.wells.wells; this.rng = new Rng(seed >>> 0).fork('world'); // Pre-simulation (genesis): how long the world evolves before frame 0, @@ -65,14 +80,37 @@ export class World { this.genesisFrames = Math.max(0, Math.round(clamp01((g - 2) / 7) * 9 * fps)); const n = field * field; + this.n = n; this.resource = new Float32Array(n); - // Tighter fertile patches than the entity world: food localized, so - // colonies deplete the ground between them into habitat barriers. - this.wells = buildWells(song.summary, field, 0.6); - this.pop = [new Float32Array(n), new Float32Array(n), new Float32Array(n)]; - this.popNext = [new Float32Array(n), new Float32Array(n), new Float32Array(n)]; - // Trail history, three tinted layers — the geography the audience sees. - this.trail = [new Float32Array(n), new Float32Array(n), new Float32Array(n)]; + this.density = new Float32Array(n); // total population per cell + + // The population, partitioned by personality (strain). + this.pop = Array.from({ length: this.m }, () => new Float32Array(n)); + this.popNext = Array.from({ length: this.m }, () => new Float32Array(n)); + + // Allegiance: the population partitioned by body. alleg[b][i] is the + // DENSITY of body-b organisms at cell i, so Σ_b alleg[b][i] equals + // the cell's total population — a concentration, advected frozen-in + // with the flux and renormalised to the population every frame. + this.B = this.program.bodies.max; + this.alleg = Array.from({ length: this.B }, () => new Float32Array(n)); + this.allegNext = Array.from({ length: this.B }, () => new Float32Array(n)); + + // Trail history, one tinted layer per body — the geography the + // audience sees, coloured by who wrote it. + this.trail = Array.from({ length: this.B }, () => new Float32Array(n)); + + // The macro-organisms themselves. + this.bodies = Array.from({ length: this.B }, (_, b) => ({ + id: b, alive: false, color: b, founded: -1, parent: -1, + cx: 0.5, cy: 0.5, presence: 0, peak: 0, + })); + this.aliveBodies = []; + // Diplomacy: pairwise affinity between bodies (consonance of their + // founding harmony bends it; the section's peace bends it live). + this.bodyAff = Array.from({ length: this.B }, () => new Float32Array(this.B)); + this.warmth = 0; + this.activity = new Float32Array(32 * 32); // Deterministic jitter: static signed noise fields from the seeded RNG, @@ -99,17 +137,30 @@ export class World { } } - // Coarse force grid: block sums of food + castes, forces per block, - // plus a fine→coarse index table. Field 128 → 64² (2×2 blocks); the - // test fields go smaller so their sims stay cheap. + // Coarse force grid: block sums of food + strains + bodies, forces per + // block, plus a fine→coarse index table. Field 128 → 64² (2×2 blocks). const fcSide = Math.max(16, Math.round(field / 2)); const fc = fcSide * fcSide; this.FC = fcSide; + this.fc = fc; this.blkR = new Float32Array(fc); - this.blkP = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; - this.blkT = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; - this.fxX = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; - this.fxY = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; + this.blkPop = Array.from({ length: this.m }, () => new Float32Array(fc)); + 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.domB = new Int8Array(fc).fill(-1); + this.subB = new Int8Array(fc).fill(-1); + this.fxX = Array.from({ length: this.m }, () => new Float32Array(fc)); + this.fxY = Array.from({ length: this.m }, () => new Float32Array(fc)); + this.velX = new Float32Array(fc); + this.velY = new Float32Array(fc); + this.wellField = new Float32Array(fc); + this.ancX = Array.from({ length: this.m }, () => new Float32Array(fc)); + this.ancY = Array.from({ length: this.m }, () => new Float32Array(fc)); + this.synergy = new Float32Array(this.m * fc); + this.valScratch = new Float64Array(this.m); + this.growScratch = new Float64Array(this.m); + this.hostility = new Float32Array(fc); this.cUp = new Int32Array(fc); this.cDown = new Int32Array(fc); this.cLeft = new Int32Array(fc); @@ -133,56 +184,34 @@ export class World { } } - // Tribe programs run on this decision grid: registers are the tribes' - // persistent memory, the action slots carry what each block decided. - const tCount = this.tribes.length; - this.trMem = new Float32Array(tCount * REGS * fc); - this.trAct = new Float32Array(tCount * ACTION_COUNT * fc); - this.trSs = new Float32Array(SENSE_COUNT); - this.trGg = new Float32Array(SENSE_COUNT * 2); - this.trScratch = new Float32Array(ACTION_COUNT); - - // Home basins: every caste is anchored to the SAME nearest well (the - // rule program's "place") so the castes interleave in one continent - // instead of colonising separate blobs. - const home = this.program.home; - const anchorStrength = home.pull; - this.anchorX = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; - this.anchorY = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; - for (let y = 0; y < fcSide; y++) { - const wy = (y + 0.5) / fcSide; - for (let x = 0; x < fcSide; x++) { - const wx = (x + 0.5) / fcSide; - let bx = 0; - let by = 0; - let bd = Infinity; - for (const well of this.wells) { - const dx = well.x - wx; - const dy = well.y - wy; - const d2 = dx * dx + dy * dy; - if (d2 < bd) { - bd = d2; - bx = dx; - by = dy; - } - } - if (bd < Infinity) { - const d = Math.sqrt(bd) + 1e-6; - for (let c = 0; c < 3; c++) { - const pull = clamp01(1 - d / home.reach) * anchorStrength[c]; - const i = y * fcSide + x; - this.anchorX[c][i] = (bx / d) * pull; - this.anchorY[c][i] = (by / d) * pull; - } - } - } - } + // 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); + this.stAct = new Float32Array(this.m * ACTION_COUNT * fc); + this.ss = new Float32Array(SENSE_COUNT); + this.gg = new Float32Array(GRAD_COUNT); + this.scratch = new Float32Array(ACTION_COUNT); + // Economy per strain. + this.caps = this.program.strains.map((s) => s.cap); + this.growth = this.program.strains.map((s) => s.growth); + this.eatR = this.program.strains.map((s) => s.eat); + this.depositR = this.program.strains.map((s) => s.deposit); + this.syn = this.program.strains.map((s) => s.synergy); this.cap = 0; - this.caps = [this.program.castes[0].cap, this.program.castes[1].cap, this.program.castes[2].cap]; - this.growth = [this.program.castes[0].growth, this.program.castes[1].growth, this.program.castes[2].growth]; - this.eatR = [this.program.castes[0].eat, this.program.castes[1].eat, this.program.castes[2].eat]; - for (let c = 0; c < 3; c++) this.cap += this.caps[c] * n; + for (let s = 0; s < this.m; s++) this.cap += this.caps[s] * n; + this.avgSpeed = this.caps.length + ? this.program.strains.reduce((a, s) => a + s.speed, 0) / this.m + : 0.05; + + // Living wells: runtime views over the compiled timeline. + this.wells = this.wellTimeline.map((w) => ({ ...w, kernel: null, kx: -9, ky: -9, peak: 1 })); + + // Home basins: every strain anchors to its NEAREST LIVING WELL — when + // wells drift, the colonies drift with them; when one dies, the pull + // moves to the next fertile place. + this.homePull = this.m > 0 ? new Float32Array(this.m).fill(1.0) : new Float32Array(0); + this.homeReach = 0.55; // History aligned with the analysis frames (metrics regress on it). this.history = { @@ -191,6 +220,7 @@ export class World { food: new Float32Array(song.analysis.frames), births: new Float32Array(song.analysis.frames), deaths: new Float32Array(song.analysis.frames), + bodies: new Float32Array(song.analysis.frames), }; this.frame = -1; @@ -200,6 +230,7 @@ export class World { this.birthsThisFrame = 0; this.deathsThisFrame = 0; + this.foundOpeningBodies(); this.seedInitial(); this.runGenesis(); } @@ -223,32 +254,76 @@ export class World { return sections[0]; } + /** How warm two bodies are to each other right now (−1 war … +1 truce). */ + diplomacy(a, b) { + if (a === b) return 1; + return Math.max(-1, Math.min(1, this.bodyAff[a][b] + this.warmth)); + } + + // ---- founding & seeding ------------------------------------------------ + + /** + * Found the opening bodies: the song's first strong events are where its + * macro-organisms take hold. Each founds a body; the founding pitch + * classes set the pairwise affinity (harmonious founders stay kin). + */ + foundOpeningBodies() { + const opening = this.program.bodies.opening; + const strong = this.song.events + .filter((e) => e.strength > 0.3) + .slice(0, 24); + const picks = []; + for (const e of strong) { + if (picks.length >= opening) break; + if (picks.every((p) => Math.hypot(p.x - e.x, p.y - e.y) > 0.22)) picks.push(e); + } + while (picks.length < opening && strong.length) { + picks.push(strong[picks.length % strong.length]); + } + const chroma = this.song.summary.chroma; + picks.forEach((e, b) => { + const body = this.bodies[b]; + body.alive = true; + body.founded = 0; + body.cx = e.x; + body.cy = e.y; + this.aliveBodies.push(b); + void chroma; + }); + // Affinity: founders born on nearby pitch classes are natural kin. + for (let a = 0; a < this.aliveBodies.length; a++) { + for (let c = a + 1; c < this.aliveBodies.length; c++) { + const aff = 0.30 * (1 - Math.min(1, Math.hypot( + this.bodies[a].cx - this.bodies[c].cx, + this.bodies[a].cy - this.bodies[c].cy) / 0.5)); + this.bodyAff[a][c] = aff; + this.bodyAff[c][a] = aff; + } + } + } + /** * Furnish the dish and wake it into the song's own opening state. * - * The wells furnish FOOD (the permanent fertile places) but not life: the - * opening colonies are spawned where the track's first bars actually land - * — the song drives where organisms appear. runGenesis() then lets several - * generations settle before frame 0. + * The wells furnish FOOD (the fertile places) but not life: the opening + * colonies are spawned where the track's first bars actually land — the + * song drives where organisms appear, and which body takes them. */ seedInitial() { const F = this.field; const res = this.resource; for (const well of this.wells) { - let peak = 0; - for (let i = 0; i < well.w.length; i++) if (well.w[i] > peak) peak = well.w[i]; - well.peak = Math.max(1e-6, peak); - const idx = well.idx; - const w = well.w; + if (well.born > 0) continue; + this.refreshKernel(well, 0); + const idx = well.kernel.idx; + const w = well.kernel.w; for (let i = 0; i < idx.length; i++) { - res[idx[i]] = Math.min(1.6, 1.2 * (w[i] / well.peak)); + res[idx[i]] = Math.min(1.6, 1.2 * w[i] / well.peak); } } // The song's own opening, replayed as the genesis: each event places - // food AND spawns every caste there (the voice first and loudest), so - // colonies rise where the track put them — not at a pre-chosen set of - // spots. A sparse opening (long beatless intro) tops itself up from the - // song's earliest hits so the world never wakes empty. + // food AND spawns life there — into the strain its voice speaks to, + // under the flag of the nearest founding body. const reach = this.genesisFrames || this.fps * 3; const pool = []; for (const e of this.song.events) { @@ -259,24 +334,46 @@ export class World { if (pool.length >= 6) break; if (!pool.includes(e)) pool.push(e); } + const vs = this.program.events.voiceStrain; + const splash = this.program.events.splash; for (const e of pool) { this.splat(e.x, e.y, e.spread, e.amount); const cell = Math.min(F - 1, (e.y * F) | 0) * F + Math.min(F - 1, (e.x * F) | 0); + const body = this.nearestBody(e.x, e.y); const burst = 2.0 + 5.0 * e.strength; - this.pop[e.voice][cell] += burst; - this.pop[(e.voice + 1) % 3][cell] += burst * 0.5; - this.pop[(e.voice + 2) % 3][cell] += burst * 0.5; + let spawned = burst; + this.pop[vs[e.voice]][cell] += burst; + for (let s = 0; s < this.m; s++) { + if (s !== vs[e.voice]) { this.pop[s][cell] += burst * 0.5 * splash[s]; spawned += burst * 0.5 * splash[s]; } + } + this.addAllegiance(cell, body, spawned); } this.recount(); this.record(-1); } + /** Body of the nearest living body-core (canonical tie-break: lowest id). */ + nearestBody(x, y) { + let best = -1; + let bd = Infinity; + for (const b of this.aliveBodies) { + const body = this.bodies[b]; + const d = Math.hypot(body.cx - x, body.cy - y); + if (d < bd - 1e-9) { bd = d; best = b; } + } + return best >= 0 ? best : (this.aliveBodies[0] ?? 0); + } + + /** Add body-density at a cell (the composition then tracks population). */ + addAllegiance(i, body, amount) { + this.alleg[body][i] += amount; + } + /** * Pre-simulate the genesis epoch (frames −G … −1). No events fire (the * index has nothing below 0), so the world settles under the intro regime: * several generations of growth, diffusion, deposition and decay decide - * where the opening's cultures actually stand. History stays untouched - * (record() skips negative frames); frame 0 is then a lived-in world. + * where the opening's bodies actually stand. History stays untouched. */ runGenesis() { const G = this.genesisFrames; @@ -287,19 +384,30 @@ export class World { this.birthsThisFrame = 0; this.deathsThisFrame = 0; this.moveAccum = 0; + this.warmth = Math.max(-1, Math.min(1, + this.program.diplomacy.base * 0.6 + 0.35 * (regime.operators.peace - 1))); + const living = this.framePrep(regime); this.inject(this.frame, regime); for (let s = 0; s < this.substeps; s++) this.substep(regime); - this.decay(regime); + this.decay(regime, living); + this.politics(regime, true); } this.frame = -1; this.pulses.length = 0; } recount() { + const n = this.n; + this.density.fill(0); let s = 0; - for (let c = 0; c < 3; c++) { + for (let c = 0; c < this.m; c++) { const p = this.pop[c]; - for (let i = 0; i < p.length; i++) s += p[i]; + const d = this.density; + for (let i = 0; i < n; i++) { + const v = p[i]; + d[i] += v; + s += v; + } } this.totalPop = s; } @@ -355,7 +463,73 @@ export class World { for (let i = 0; i < idx.length; i++) res[idx[i]] += w[i] * k; } - // ---- step ------------------------------------------------------------ + // ---- wells --------------------------------------------------------------- + + /** (Re)build a living well's recharge kernel at its current position. */ + refreshKernel(well, frame) { + const [x, y] = wellPos(well, frame); + const minStep = 0.6 / this.field; + if (!well.kernel || Math.abs(x - well.kx) > minStep || Math.abs(y - well.ky) > minStep) { + well.kernel = buildKernel(this.field, x, y, well.sigma); + well.kx = x; + well.ky = y; + let peak = 0; + for (let i = 0; i < well.kernel.w.length; i++) if (well.kernel.w[i] > peak) peak = well.kernel.w[i]; + well.peak = Math.max(1e-6, peak); + } + } + + /** The wells alive at a frame, with their recharge envelope. */ + livingWells(frame) { + const out = []; + for (const w of this.wells) { + const env = wellEnv(w, frame, this.fps); + if (env <= 0) continue; + this.refreshKernel(w, frame); + out.push({ well: w, env }); + } + return out; + } + + /** A land-rush: a well is born — food floods in, the nearest body claims. */ + wellBirth(well, frame, spawnOp = 1) { + const [x, y] = wellPos(well, frame); + this.splat(x, y, well.sigma * 1.6, 2.5); + const F = this.field; + const cell = Math.min(F - 1, (y * F) | 0) * F + Math.min(F - 1, (x * F) | 0); + const body = this.nearestBody(x, y); + const vs = this.program.events.voiceStrain; + const burst = (0.25 + 1.2 * spawnOp) * 3.0; + for (let s = 0; s < this.m; s++) { + this.pop[s][cell] += burst; + } + this.addAllegiance(cell, body, burst); + const ax = Math.max(0, Math.min(31, (x * 32) | 0)); + const ay = Math.max(0, Math.min(31, (y * 32) | 0)); + this.activity[ay * 32 + ax] += 4; + } + + // ---- step --------------------------------------------------------------- + + /** Frame setup shared by step() and the genesis epoch. */ + framePrep(regime) { + const f = this.frame; + // The section's warmth: the diplomacy base bent by the peace operator. + const ops = regime.operators; + this.warmth = Math.max(-1, Math.min(1, + this.program.diplomacy.base * 0.6 + 0.35 * (ops.peace - 1))); + + // Living wells: births fire their land-rush (as hard as the section's + // spawn operator allows); the well field (the coarse attraction the + // strains smell) is rebuilt for this frame. + for (const w of this.wells) { + if (f === w.born) this.wellBirth(w, f, ops.spawn); + } + const living = this.livingWells(f); + this.updateWellField(living); + this.updateAnchors(living); + return living; + } step() { this.frame++; @@ -368,32 +542,99 @@ export class World { this.deathsThisFrame = 0; this.moveAccum = 0; + const living = this.framePrep(regime); + this.inject(f, regime); for (let s = 0; s < this.substeps; s++) this.substep(regime); - this.decay(regime); + this.decay(regime, living); + this.politics(regime, false); this.record(f); return regime; } + /** Coarse attraction field of the living wells (the strains smell this). */ + updateWellField(living) { + const FC = this.FC; + const fc = FC * FC; + this.wellField.fill(0); + for (const { well, env } of living) { + const [x, y] = wellPos(well, this.frame); + const bx = x * (FC - 1); + const by = y * (FC - 1); + const sig = Math.max(1.4, well.sigma * this.field * 0.5); + const r = Math.ceil(sig * 2.2); + const denom = 1 / (2 * sig * sig); + for (let dy = -r; dy <= r; dy++) { + const py = Math.round(by) + dy; + if (py < 0 || py >= FC) continue; + for (let dx = -r; dx <= r; dx++) { + const px = Math.round(bx) + dx; + if (px < 0 || px >= FC) continue; + const g = Math.exp(-(dx * dx + dy * dy) * denom) * env; + this.wellField[py * FC + px] += g * 0.12; + } + } + } + } + + /** Home anchors: each strain is pulled to its nearest living well. */ + updateAnchors(living) { + const FC = this.FC; + const fc = FC * FC; + const m = this.m; + const pull = 1.1; + for (let b = 0; b < fc; b++) { + const wx = (b % FC + 0.5) / FC; + const wy = (((b / FC) | 0) + 0.5) / FC; + let bx = 0; + let by = 0; + let bd = Infinity; + for (const { well } of living) { + const [x, y] = wellPos(well, this.frame); + const dx = x - wx; + const dy = y - wy; + const d2 = dx * dx + dy * dy; + if (d2 < bd) { bd = d2; bx = dx; by = dy; } + } + for (let s = 0; s < m; s++) { + const i = b; + if (bd < Infinity) { + const d = Math.sqrt(bd) + 1e-6; + const p = clamp01(1 - d / this.homeReach) * pull * this.homePull[s]; + this.ancX[s][i] = (bx / d) * p; + this.ancY[s][i] = (by / d) * p; + } else { + this.ancX[s][i] = 0; + this.ancY[s][i] = 0; + } + } + } + } + inject(frame, regime) { const events = this.song.index[frame]; if (!events) return; const F = this.field; const spawn = regime.operators.spawn; + 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 // drop regime's spawn operator floods the ground with colonies; the // intro barely seeds. const scale = 0.4 + 1.1 * spawn; for (const e of events) { - // Food where the track put it. The voice that spoke feeds first - // and loudest; the splash rule gives the other castes their share. + // Food where the track put it. this.splat(e.x, e.y, e.spread, e.amount * scale); const cell = Math.min(F - 1, (e.y * F) | 0) * F + Math.min(F - 1, (e.x * F) | 0); const boost = (0.5 + 3.0 * e.strength) * scale; - this.pop[e.voice][cell] += boost; - this.pop[(e.voice + 1) % 3][cell] += boost * splash[(e.voice + 1) % 3]; - this.pop[(e.voice + 2) % 3][cell] += boost * splash[(e.voice + 2) % 3]; + const body = this.nearestBody(e.x, e.y); + const lead = vs[e.voice]; + this.pop[lead][cell] += boost; + let spawned = boost; + for (let s = 0; s < this.m; s++) { + if (s !== lead) { this.pop[s][cell] += boost * splash[s]; spawned += boost * splash[s]; } + } + this.addAllegiance(cell, body, spawned); 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; @@ -405,36 +646,43 @@ export class World { } /** - * Run every tribe's program on every decision block. Senses are read from - * the block sums + gradients; the program branches on them, updates its - * persistent registers (memory) and emits actions. The MOVE actions become - * the advection forces; the others are stored for the reaction phase. - * A faint home anchor stays so colonies keep their place. + * Run every strain's program on every decision block. Senses are read + * from the block sums + gradients — including the body the local + * organisms belong to (KIN), the other bodies (FOE), the living wells, + * and the section's PEACE. The MOVE actions become the advection forces; + * the others are stored for the reaction phase. */ - runTribes(regime) { + runStrains(regime) { const FC = this.FC; const fc = FC * FC; - const bP = this.blkP; - const blkT = this.blkT; + const m = this.m; + const blkPop = this.blkPop; + const blkTrail = this.blkTrail; + const blkBody = this.blkBody; const blkR = this.blkR; - const tribes = this.tribes; - const T = tribes.length; - const ss = this.trSs; - const gg = this.trGg; - const act = this.trScratch; - const mem = this.trMem; - const actBuf = this.trAct; + const blkTotal = this.blkTotal; + const domB = this.domB; + const strains = this.strains; + const ss = this.ss; + const gg = this.gg; + const act = this.scratch; + const mem = this.stMem; + const actBuf = this.stAct; const cU = this.cUp; const cD = this.cDown; const cL = this.cLeft; const cR = this.cRight; const fxX = this.fxX; const fxY = this.fxY; + const synScratch = this.synergy; + const wellField = this.wellField; const now = this.frame / this.fps; const start = regime.start; const span = Math.max(0.001, regime.end - regime.start); const progT = clamp01((now - start) / span); const lawSpawn = regime.operators.spawn; + const peace = clamp01((this.warmth + 1) / 2); + const actFC = FC * FC; for (let b = 0; b < fc; b++) { const ug = cU[b]; @@ -442,273 +690,553 @@ export class World { const lg = cL[b]; const rg = cR[b]; const r0 = blkR[b]; - const tot = bP[0][b] + bP[1][b] + bP[2][b]; - // Gradients of food and the three castes at this block. + const tot = blkTotal[b]; + const dom = domB[b]; + const domPres = dom >= 0 ? blkBody[dom][b] : 0; + + // Gradients of food and total population at this block. const gRx = blkR[rg] - blkR[lg]; const gRy = blkR[dg] - blkR[ug]; - const g0x = bP[0][rg] - bP[0][lg]; - const g0y = bP[0][dg] - bP[0][ug]; - const g1x = bP[1][rg] - bP[1][lg]; - const g1y = bP[1][dg] - bP[1][ug]; - const g2x = bP[2][rg] - bP[2][lg]; - const g2y = bP[2][dg] - bP[2][ug]; - const gr = [g0x, g1x, g2x]; - const gy2 = [g0y, g1y, g2y]; + // Population pressure: crowded ground pushes outward. This is the + // disperse operator's physics — a saturated colony spills over + // its rim instead of packing to the ceiling and stalling. + const dgx = blkTotal[rg] - blkTotal[lg]; + const dgy = blkTotal[dg] - blkTotal[ug]; + const pressure = 0.022 * regime.operators.disperse * clamp01(tot / 12); - for (let t = 0; t < T; t++) { - const a = (t + 1) % T; - const bb = (t + 2) % T; - const own = bP[t][b]; - const oA = bP[a][b]; - const oB = bP[bb][b]; - const nb = (bP[t][ug] + bP[t][dg] + bP[t][lg] + bP[t][rg]) / 4; + for (let s = 0; s < m; s++) { + const own = blkPop[s][b]; + const kinPres = dom >= 0 ? Math.max(0, blkBody[dom][b] - own) : 0; + const foe = Math.max(0, tot - domPres); + const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4; // ---- senses ------------------------------------------------- - ss[0] = clamp01(r0 * 0.12); // FOOD - ss[1] = clamp01(own * 0.008); // SELF - ss[2] = clamp01(oA * 0.008); // OTHER0 - ss[3] = clamp01(oB * 0.008); // OTHER1 - ss[4] = clamp01(blkT[t][b] * 0.05); // TRAIL - ss[5] = clamp01(lawSpawn / 2.2); // LAW (spawn) - ss[6] = 1 - ss[0]; // HUNGER - ss[7] = clamp01(Math.sign(own - nb) * 0.5 + 0.5); // EDGE (rim) - ss[8] = progT; // TIME - ss[9] = clamp01(tot * 0.005); // TOTAL + ss[S_FOOD] = clamp01(r0 * 0.12); + ss[S_SELF] = clamp01(own * 0.008); + ss[S_KIN] = clamp01(kinPres * 0.008); + ss[S_FOE] = clamp01(foe * 0.008); + ss[S_TRAIL] = dom >= 0 ? clamp01(blkTrail[dom][b] * 0.05) : 0; + ss[S_LAW] = clamp01(lawSpawn / 2.2); + ss[S_HUNGER] = 1 - ss[S_FOOD]; + ss[S_EDGE] = clamp01(Math.sign(own - nb) * 0.5 + 0.5); + ss[S_TIME] = progT; + ss[S_TOTAL] = clamp01(tot * 0.005); + ss[S_WELL] = clamp01(wellField[b]); + ss[S_PEACE] = peace; - // ---- gradients (sign per field × axis) ----------------------- - gg[0] = Math.sign(gRx); - gg[1] = Math.sign(gRy); - gg[2] = Math.sign(gr[t]); - gg[3] = Math.sign(gy2[t]); - gg[4] = Math.sign(gr[a]); - gg[5] = Math.sign(gy2[a]); - gg[6] = Math.sign(gr[bb]); - gg[7] = Math.sign(gy2[bb]); - gg[8] = gg[2]; - gg[9] = gg[3]; - gg[10] = Math.sign(gRx + gr[0] + gr[1] + gr[2]); - gg[11] = Math.sign(gRy + gy2[0] + gy2[1] + gy2[2]); + // ---- gradients (sign per field × axis) --------------------- + gg[G_FOOD * 2] = Math.sign(gRx); + gg[G_FOOD * 2 + 1] = Math.sign(gRy); + gg[G_SELF * 2] = Math.sign(this.gradOf(blkPop[s], b, ug, dg, lg, rg)); + gg[G_SELF * 2 + 1] = this.gradY(blkPop[s], b, ug, dg); + gg[G_KIN * 2] = 0; + gg[G_KIN * 2 + 1] = 0; + gg[G_FOE * 2] = 0; + gg[G_FOE * 2 + 1] = 0; + if (dom >= 0) { + gg[G_KIN * 2] = Math.sign(blkBody[dom][rg] - blkBody[dom][lg]); + gg[G_KIN * 2 + 1] = Math.sign(blkBody[dom][dg] - blkBody[dom][ug]); + // foe gradient: the other bodies' presence, summed. + let fx = 0; + let fy = 0; + for (const c of this.aliveBodies) { + if (c === dom) continue; + fx += blkBody[c][rg] - blkBody[c][lg]; + fy += blkBody[c][dg] - blkBody[c][ug]; + } + gg[G_FOE * 2] = Math.sign(fx); + gg[G_FOE * 2 + 1] = Math.sign(fy); + } + gg[G_WELL * 2] = Math.sign(wellField[rg] - wellField[lg]); + gg[G_WELL * 2 + 1] = Math.sign(wellField[dg] - wellField[ug]); - runTribe(mem, t * REGS * fc + b, ss, gg, act, tribes[t].code); + runProgram(this.stMem, s * REGS * FC + b, ss, gg, act, strains[s].code); - // ---- actions → forces + reaction mods -------------------------- - fxX[t][b] = act[0] + this.anchorX[t][b] * 0.3 + gRx * 0.02; - fxY[t][b] = act[1] + this.anchorY[t][b] * 0.3 + gRy * 0.02; - const off = t * ACTION_COUNT * fc + b; - actBuf[off + 2 * fc] = act[2]; // EXCRETE - actBuf[off + 3 * fc] = act[3]; // EAT - actBuf[off + 4 * fc] = act[4]; // HIT - actBuf[off + 5 * fc] = act[5]; // SPAWN + // ---- actions → forces + reaction mods ---------------------- + fxX[s][b] = act[0] + this.ancX[s][b] * 0.12 + gRx * 0.02 - dgx * pressure; + fxY[s][b] = act[1] + this.ancY[s][b] * 0.12 + gRy * 0.02 - dgy * pressure; + const off = s * ACTION_COUNT * actFC + b; + actBuf[off + 2 * actFC] = act[2]; // EXCRETE + actBuf[off + 3 * actFC] = act[3]; // EAT + actBuf[off + 4 * actFC] = act[4]; // HIT + actBuf[off + 5 * actFC] = act[5]; // SPAWN + + // Synergy: this strain breeds fastest in a mixed, same-body + // crowd — the share of the block's population that is kin. + const syn = this.syn[s]; + const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0; + synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare; } } } + gradOf(arr, b, ug, dg, lg, rg) { return Math.sign(arr[rg] - arr[lg]); } + gradY(arr, b, ug, dg) { return Math.sign(arr[dg] - arr[ug]); } + /** - * One simulation substep, three phases: - * 1. forces on the coarse grid (block sums → scaled gradients); - * 2. advection + diffusion on the fine field (L1 axis split, no sqrt); - * 3. reaction: eat → grow → deposit, starve. + * One simulation substep: + * 1. block sums (food, strains, body presence, dominant bodies); + * 2. the strain programs decide the forces; + * 3. advection + diffusion on the fine field (strains by their own + * force; allegiance frozen-in with the population flux); + * 4. reaction: eat → grow (with synergy) → war (casualties + + * conversion) → deposit into the body's trail. * Deterministic throughout: the only randomness is the static noise field. */ substep(regime) { const F = this.field; const FC = this.FC; + const fc = FC * FC; const sub = 1 / (this.fps * this.substeps); const ops = regime.operators; const prog = this.program; - const castes = prog.castes; + const strains = prog.strains; + const m = this.m; const diff = prog.memory.diffusion; const jitterAmt = prog.memory.jitter * (0.4 + 1.35 * ops.turbulence); const capMul = 0.7 + 0.6 * ops.split; const pop = this.pop; const next = this.popNext; + const alleg = this.alleg; + const allegNext = this.allegNext; const trail = this.trail; const res = this.resource; + const density = this.density; const nUp = this.nUp; const nDown = this.nDown; const nLeft = this.nLeft; const nRight = this.nRight; - const cUp = this.cUp; - const cDown = this.cDown; - const cLeft = this.cLeft; - const cRight = this.cRight; const coarseOf = this.coarseOf; const noise = this.noise; const noise2 = this.noise2; - const pCount = pop[0].length; - const p0 = pop[0]; - const p1 = pop[1]; - const p2 = pop[2]; + const n = this.n; - // ---- 1. block sums → tribe programs decide the forces -------------------- + // ---- 1. block sums ---------------------------------------------------- const blkR = this.blkR; - const bP0 = this.blkP[0]; - const bP1 = this.blkP[1]; - const bP2 = this.blkP[2]; - const bT0 = this.blkT[0]; - const bT1 = this.blkT[1]; - const bT2 = this.blkT[2]; + const blkPop = this.blkPop; + const blkTrail = this.blkTrail; + const blkBody = this.blkBody; + const blkTotal = this.blkTotal; blkR.fill(0); - bP0.fill(0); - bP1.fill(0); - bP2.fill(0); - bT0.fill(0); - bT1.fill(0); - bT2.fill(0); - for (let i = 0; i < pCount; i++) { + blkTotal.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]; - bP0[b] += p0[i]; - bP1[b] += p1[i]; - bP2[b] += p2[i]; - bT0[b] += trail[0][i]; - bT1[b] += trail[1][i]; - bT2[b] += trail[2][i]; + if (d > 1e-6) { + blkTotal[b] += d; + for (let s = 0; s < m; s++) blkPop[s][b] += pop[s][i]; + for (let c = 0; c < this.B; c++) { + const a = alleg[c][i]; + if (a > 1e-6) { + blkBody[c][b] += a; + blkTrail[c][b] += trail[c][i]; + } + } + } + } + // Dominant + second body per block (canonical: ties → lowest id). + const domB = this.domB; + const subB = this.subB; + for (let b = 0; b < fc; b++) { + let d0 = -1; + let d1 = -1; + let v0 = 0; + let v1 = 0; + for (const c of this.aliveBodies) { + const v = blkBody[c][b]; + if (v > v0) { v1 = v0; d1 = d0; v0 = v; d0 = c; } + else if (v > v1) { v1 = v; d1 = c; } + } + domB[b] = d0; + subB[b] = d1; } - this.runTribes(regime); - // ---- 2. advection + diffusion on the fine field ------------------------- - for (let c = 0; c < 3; c++) { - const p = pop[c]; - const q = next[c]; - q.fill(0); - const fxArr = this.fxX[c]; - const fyArr = this.fxY[c]; - const speed = castes[c].speed * ops.speed; - const frac = Math.min(0.4, speed * sub * 240); + // ---- 2. the strain programs decide the forces -------------------------- + this.runStrains(regime); - for (let i = 0; i < pCount; i++) { - const v = p[i]; - if (v <= 1e-6) continue; - const b = coarseOf[i]; - const vx = fxArr[b] + noise[i] * jitterAmt; - const vy = fyArr[b] + noise2[i] * jitterAmt; - const w = Math.abs(vx) + Math.abs(vy); + // The per-block war pressure: hits × hostile presence. A body strikes + // its neighbour only where the two actually touch. + const actBuf = this.stAct; + const actFC = FC * FC; + const hostility = this.hostility; + hostility.fill(0); + for (let b = 0; b < fc; b++) { + const dom = domB[b]; + const other = subB[b]; + if (dom < 0 || other < 0) continue; + const dip = this.diplomacy(dom, other); + if (dip >= 0) continue; + let hits = 0; + for (let s = 0; s < m; s++) hits += actBuf[s * ACTION_COUNT * actFC + 4 * actFC + b]; + const foeShare = clamp01(blkBody[other][b] / Math.max(1e-6, blkTotal[b])); + hostility[b] = Math.min(1, hits * 8) * (-dip) * foeShare; + } - const leave = v * frac; - if (leave > 0 && w > 1e-6) { - const mx = (leave * Math.abs(vx)) / w; - const my = (leave * Math.abs(vy)) / w; - q[i] += v - leave; - q[vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i] += mx; - q[vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i] += my; + // ---- 3. advection + diffusion ------------------------------------------ + // Allegiance rides the population flux: the density-weighted mean of + // the strains' velocities. + const velX = this.velX; + const velY = this.velY; + for (let b = 0; b < fc; b++) { + let sx = 0; + let sy = 0; + let w = 0; + for (let s = 0; s < m; s++) { + const p = blkPop[s][b]; + sx += this.fxX[s][b] * p; + sy += this.fxY[s][b] * p; + w += p; + } + velX[b] = w > 1e-6 ? sx / w : 0; + velY[b] = w > 1e-6 ? sy / w : 0; + } + const advFrac = Math.min(0.4, this.avgSpeed * ops.speed * sub * 240); + for (let s = 0; s < m; s++) { + this.advect(pop[s], next[s], this.fxX[s], this.fxY[s], + advFrac, jitterAmt, diff, sub, true); + } + const allegDiff = diff; // allegiance is frozen-in: it travels exactly + // with the population's own spreading + for (let c = 0; c < this.B; c++) { + this.advect(alleg[c], allegNext[c], velX, velY, advFrac, 0, allegDiff, sub, false); + } + + // ---- 4. react ----------------------------------------------------------- + const societyCeil = prog.society.ceiling; + const globalCap = prog.society.global; + const synScratch = this.synergy; + const vals = this.valScratch; + const grown = this.growScratch; + // The dish's carrying capacity: as the whole world approaches it, + // breeding eases off everywhere (soft, so it never bites hard). + const globalDamp = Math.max(0, 1 - Math.max(0, this.totalPop - globalCap) / globalCap); + for (let i = 0; i < n; i++) { + const r0 = res[i]; + const b = coarseOf[i]; + const dom = domB[b]; + if (r0 < 1e-6 && density[i] < 1e-6) continue; + + let total = 0; + for (let s = 0; s < m; s++) vals[s] = next[s][i]; + + // Saturated food factor (rational approximation, no transcendentals). + const foodFactor = (r0 * 5) / (1 + r0 * 5); + // The gate is nearly shut on bare ground: food is the substrate, not a + // catalyst. A colony that outgrows its ground starves back and must + // wander — the world tracks its wells instead of congealing. + const gate = 0.03 + 0.97 * foodFactor; + + // War: the block's contest bleeds the losing body's share here. + const host = hostility[b]; + let casualty = 0; + if (host > 1e-9 && dom >= 0) { + const other = subB[b] >= 0 ? subB[b] : dom; + const loseShare = dom !== other + ? allegNext[other][i] / Math.max(1e-6, density[i]) + : 0; + casualty = host * 2.5 * sub * loseShare; + } + + let demand = 0; + for (let s = 0; s < m; s++) demand += vals[s] * this.eatR[s] * actBuf[s * ACTION_COUNT * actFC + 3 * actFC + b]; + 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 society = Math.max(0, 1 - density[i] / societyCeil); + for (let s = 0; s < m; s++) { + const p = vals[s]; + const spn = actBuf[s * ACTION_COUNT * actFC + 5 * actFC + b]; + const synB = synScratch[s * FC + b]; + let g = p * this.growth[s] * ops.growth * globalDamp * (1 + spn) * synB * sub * (1 - p / (this.caps[s] * capMul)) * gate; + g *= (1 - casualty * 8); + // Starvation is the body's metabolic floor: bare ground kills, + // slowly but surely — a colony that sits still on exhausted + // ground dissolves, and the survivors must wander. This is + // what keeps the world working instead of congealing. (The + // eat action drives depletion above; it is not the price of + // eating.) + g = Math.max(0, p + g * society) - p * starve * this.eatR[s]; + g = Math.max(0, g); + if (casualty > 0) g -= g * casualty * 6; // war kills; it does not convert + grown[s] = g; + total += g; + } + + // Deposit into the DOMINANT body's trail: each cell's organisms + // write the geography of the body they belong to. + if (total > 1e-9 && dom >= 0) { + const tB = trail[dom]; + for (let s = 0; s < m; s++) { + const exc = actBuf[s * ACTION_COUNT * actFC + 2 * actFC + b]; + tB[i] += grown[s] * this.depositR[s] * ops.deposit * (1 + exc) * sub * DEPOSIT_SCALE; + } + } + + let dNew = 0; + for (let s = 0; s < m; s++) { + const before = pop[s][i]; + const after = grown[s]; + next[s][i] = after; + dNew += after; + if (after > before) this.birthsThisFrame += after - before; + else if (after < before) this.deathsThisFrame += before - after; + } + density[i] = dNew; + + // The composition grows with the cell: newborn organisms inherit + // the standing mix; an allegiance-less cell joins the dominant + // body wholesale. + if (dNew > 1e-6) { + let S = 0; + for (let c = 0; c < this.B; c++) S += allegNext[c][i]; + if (S > 1e-4) { + const k = dNew / S; + for (let c = 0; c < this.B; c++) allegNext[c][i] *= k; + } else if (dom >= 0) { + for (let c = 0; c < this.B; c++) allegNext[c][i] = 0; + allegNext[dom][i] = dNew; + } + } + } + + // ---- swap --------------------------------------------------------------- + let totalPop = 0; + for (let s = 0; s < m; s++) { + const t = pop[s]; + pop[s] = next[s]; + next[s] = t; + const p = pop[s]; + for (let i = 0; i < n; i++) totalPop += p[i]; + } + for (let c = 0; c < this.B; c++) { + const t = alleg[c]; + alleg[c] = allegNext[c]; + allegNext[c] = t; + } + this.totalPop = totalPop; + } + + /** + * Conservative L1 advection + diffusion of one field, shared by the + * strains (which track activity) and the allegiance composition. `frac` + * is the fraction of a cell that departs this substep. + */ + advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track) { + const F = this.field; + const nUp = this.nUp; + const nDown = this.nDown; + const nLeft = this.nLeft; + const nRight = this.nRight; + const coarseOf = this.coarseOf; + const noise = this.noise; + const noise2 = this.noise2; + const n = this.n; + dst.fill(0); + for (let i = 0; i < n; i++) { + const v = src[i]; + if (v <= 1e-6) { + continue; + } + const b = coarseOf[i]; + const vx = fxArr[b] + (track ? noise[i] * jitterAmt : 0); + const vy = fyArr[b] + (track ? noise2[i] * jitterAmt : 0); + const w = Math.abs(vx) + Math.abs(vy); + + const leave = v * frac; + if (leave > 0 && w > 1e-6) { + 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; + + if (track) { const ax = ((i % F) * 32 / F) | 0; const ay = ((i / F) * 32 / F) | 0; this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6; this.moveAccum += leave; - } else { - q[i] += v; } - - // Diffusion through the four neighbours (conservative). - const d = (p[nUp[i]] + p[nDown[i]] + p[nLeft[i]] + p[nRight[i]] - 4 * v) * diff * sub; - q[i] += d * 0.5; - q[nUp[i]] -= d * 0.125; - q[nDown[i]] -= d * 0.125; - q[nLeft[i]] -= d * 0.125; - q[nRight[i]] -= d * 0.125; - } - } - - // ---- 3. react: eat, grow, die, deposit -------------------------------- - const mutual = castes.map((c) => c.mutual); - const social = castes.map((c) => c.social); - const societyCeil = prog.society.ceiling; - const globalCap = prog.society.global; - const actBuf = this.trAct; - const actStride = ACTION_COUNT * this.FC * this.FC; - const actFC = this.FC * this.FC; - // The dish's carrying capacity: as the whole world approaches it, - // breeding eases off everywhere (soft, so it never bites hard). - const globalDamp = Math.max(0, 1 - Math.max(0, this.totalPop - globalCap) / globalCap); - for (let i = 0; i < pCount; i++) { - const r0 = res[i]; - let pa = next[0][i]; - let pb = next[1][i]; - let pc = next[2][i]; - if (r0 < 1e-6 && pa < 1e-6 && pb < 1e-6 && pc < 1e-6) continue; - const b = coarseOf[i]; - const base0 = b; - const base1 = actStride + b; - const base2 = actStride * 2 + b; - // What this block's tribes decided this substep. - const eat0 = actBuf[base0 + 3 * actFC]; - const eat1 = actBuf[base1 + 3 * actFC]; - const eat2 = actBuf[base2 + 3 * actFC]; - const exc0 = actBuf[base0 + 2 * actFC]; - const exc1 = actBuf[base1 + 2 * actFC]; - const exc2 = actBuf[base2 + 2 * actFC]; - const spn0 = actBuf[base0 + 5 * actFC]; - const spn1 = actBuf[base1 + 5 * actFC]; - const spn2 = actBuf[base2 + 5 * actFC]; - const hit0 = actBuf[base0 + 4 * actFC]; - const hit1 = actBuf[base1 + 4 * actFC]; - const hit2 = actBuf[base2 + 4 * actFC]; - - // Saturated food factor (rational approximation, no transcendentals). - const foodFactor = (r0 * 5) / (1 + r0 * 5); - const gate = 0.10 + 0.9 * foodFactor; - - // Aggression: a tribe that hit thins its rivals in this cell. - if (hit0 > 1e-9 || hit1 > 1e-9 || hit2 > 1e-9) { - const thin = (p) => Math.max(0, p - p * Math.min(0.04, Math.max(0, hit0 + hit1 + hit2) * 0.4) * sub); - pb = thin(pb); - pc = thin(pc); - pa = thin(pa); + } else { + dst[i] += v; } - let demand = pa * this.eatR[0] * eat0 + pb * this.eatR[1] * eat1 + pc * this.eatR[2] * eat2; - demand *= sub; - res[i] = demand > 0 ? Math.max(0, r0 - Math.min(r0, demand)) : r0; - - const grow = (p, base, cap, spn) => - p * base * ops.growth * globalDamp * (1 + spn) * sub * (1 - p / (cap * capMul)) * gate; - const starve = (1 - foodFactor) * (0.10 + 0.9 * ops.spawn) * sub; - // A soft society ceiling (~2× the sum of caste caps): the three can - // pack a cell but none can flood the dish. - const society = Math.max(0, 1 - (pa + pb + pc) / societyCeil); - // Mutualism: a caste grows best among the others — mixed crowds - // out-breed monochrome blobs (the rule program says so). - const bonus0 = mutual[0].base + (1 - mutual[0].base) * clamp01((pb + pc) / mutual[0].threshold); - const bonus1 = mutual[1].base + (1 - mutual[1].base) * clamp01((pa + pc) / mutual[1].threshold); - const bonus2 = mutual[2].base + (1 - mutual[2].base) * clamp01((pa + pb) / mutual[2].threshold); - // Social floor: the spark only blooms among fellow sparks. - const socialSpark = social[2] ? clamp01((pc - social[2].floor) / social[2].curve) : 1; - const na = Math.max(0, pa + grow(pa, this.growth[0], this.caps[0], spn0) * society * bonus0 - pa * starve * this.eatR[0] * eat0); - const nb = Math.max(0, pb + grow(pb, this.growth[1], this.caps[1], spn1) * society * bonus1 - pb * starve * this.eatR[1] * eat1); - const nc = Math.max(0, pc + grow(pc, this.growth[2], this.caps[2], spn2) * society * bonus2 * socialSpark - pc * starve * this.eatR[2] * eat2); - - next[0][i] = na; - next[1][i] = nb; - next[2][i] = nc; - - trail[0][i] += na * castes[0].deposit * ops.deposit * (1 + exc0) * sub * DEPOSIT_SCALE; - trail[1][i] += nb * castes[1].deposit * ops.deposit * (1 + exc1) * sub * DEPOSIT_SCALE; - trail[2][i] += nc * castes[2].deposit * ops.deposit * (1 + exc2) * sub * DEPOSIT_SCALE; - - if (na > pa) this.birthsThisFrame += na - pa; - if (nb > pb) this.birthsThisFrame += nb - pb; - if (nc > pc) this.birthsThisFrame += nc - pc; - if (na < pa) this.deathsThisFrame += pa - na; - if (nb < pb) this.deathsThisFrame += pb - nb; - if (nc < pc) this.deathsThisFrame += pc - nc; + // 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; } - - // Swap and re-sum the population (count feeds history and the HUD). - let total = 0; - for (let c = 0; c < 3; c++) { - const t = pop[c]; - pop[c] = next[c]; - next[c] = t; - const p = pop[c]; - for (let i = 0; i < p.length; i++) total += p[i]; - } - this.totalPop = total; } - decay(regime) { + // ---- the politics of allegiance (frame level) ---------------------------- + + /** + * Allegiance changes here — never inside a substep: + * defection a body whose presence collapsed releases its members to + * the strongest neighbour, cell by cell; + * fission a coherent, mixed, cut-off splinter hit by a strong onset + * founds a new body (a schism: rival to its parent); + * merger two intermixed bodies under a cooperative regime unify. + */ + politics(regime, silent) { + const n = this.n; + const F = this.field; + const density = this.density; + const alleg = this.alleg; + const f = this.frame; + void silent; + + // ---- the composition tracks the population: scale each cell's + // allegiance to its actual population, claim frontier cells for the + // nearest body, and fade the claims of abandoned ground. + for (let i = 0; i < n; i++) { + const target = density[i]; + let S = 0; + for (const b of this.aliveBodies) { + if (alleg[b][i] < 0) alleg[b][i] = 0; // advection overshoot + S += alleg[b][i]; + } + if (target > 1e-6) { + if (S > 1e-4) { + const k = target / S; + for (const b of this.aliveBodies) alleg[b][i] *= k; + } else { + const x = (i % F) / F; + const y = ((i / F) | 0) / F; + let best = this.aliveBodies[0]; + let bd = Infinity; + for (const b of this.aliveBodies) { + const d = Math.hypot(this.bodies[b].cx - x, this.bodies[b].cy - y); + if (d < bd - 1e-9) { bd = d; best = b; } + } + if (best !== undefined) { + for (let c = 0; c < this.B; c++) alleg[c][i] = 0; + alleg[best][i] = target; + } + } + } else if (S > 1e-9) { + // the population left: the claim fades + for (const b of this.aliveBodies) alleg[b][i] *= 0.82; + } + } + + // Presence per body + weighted centroid. + for (const b of this.aliveBodies) { + const body = this.bodies[b]; + body.presence = 0; + } + const presX = new Float64Array(this.B); + const presY = new Float64Array(this.B); + for (let i = 0; i < n; i++) { + for (const b of this.aliveBodies) { + const a = alleg[b][i]; + if (a > 1e-6) { + this.bodies[b].presence += a; + presX[b] += (i % F) * a; + presY[b] += ((i / F) | 0) * a; + } + } + } + for (const b of this.aliveBodies) { + const body = this.bodies[b]; + body.cx = body.presence > 1e-6 ? presX[b] / body.presence / F : body.cx; + body.cy = body.presence > 1e-6 ? presY[b] / body.presence / F : body.cy; + body.peak = Math.max(body.peak, body.presence); + } + + // ---- colonisation: a settlement far from every body core, blessed + // by a strong onset, founds a NEW body. Rare, and it never flips an + // existing body's ground — it plants a new flag on the frontier. + if (f > 0 && f % COLONIZE_EVERY === 0 && this.aliveBodies.length < this.B) { + const FC = this.FC; + const fc = FC * FC; + let best = -1; + let bestScore = 0; + for (let b = 0; b < fc; b++) { + const tot = this.blkTotal[b]; + if (tot < 10) continue; + const wx = ((b % FC) + 0.5) / FC; + const wy = (((b / FC) | 0) + 0.5) / FC; + // Far from EVERY living body: virgin ground, not a border. + let nearest = Infinity; + for (const c of this.aliveBodies) { + nearest = Math.min(nearest, Math.hypot(this.bodies[c].cx - wx, this.bodies[c].cy - wy)); + } + if (nearest < 0.22) continue; + const score = tot * Math.min(1, nearest / 0.35); + if (score > bestScore) { bestScore = score; best = b; } + } + // A founding is blessed by a strong onset nearby, recent. + if (best >= 0 && bestScore > 8.0) { + const FCs = this.FC; + const wx = ((best % FCs) + 0.5) / FCs; + const wy = (((best / FCs) | 0) + 0.5) / FCs; + let blessed = false; + for (const p of this.pulses) { + if (p.strength > 0.7 && Math.hypot(p.x - wx, p.y - wy) < 0.14) { blessed = true; break; } + } + if (blessed) this.foundBody(best, f); + } + } + } + + /** Found a new body from the splinter at coarse block `block`. */ + foundBody(block, frame) { + const slot = this.bodies.findIndex((b) => !b.alive); + if (slot < 0) return -1; + const F = this.field; + const FC = this.FC; + const fc = FC * FC; + const body = this.bodies[slot]; + const usedColors = new Set(this.aliveBodies.map((b) => this.bodies[b].color)); + let color = 0; + while (usedColors.has(color) && color < this.B) color++; + body.alive = true; + body.founded = frame; + body.parent = -1; + body.color = Math.min(color, this.B - 1); + body.cx = ((block % FC) + 0.5) / FC; + body.cy = (((block / FC) | 0) + 0.5) / FC; + body.peak = 0; + body.presence = 0; + this.aliveBodies.push(slot); + // A new polity has no friends yet: wary neutrality toward everyone. + const alleg = this.alleg; + const bs = F / FC; + const bx = (block % FC) * bs; + const by = ((block / FC) | 0) * bs; + for (let y = by - 2; y < by + bs + 2; y++) { + for (let x = bx - 2; x < bx + bs + 2; x++) { + if (x < 0 || y < 0 || x >= F || y >= F) continue; + const i = y * F + x; + let S = 0; + for (let c = 0; c < this.B; c++) S += alleg[c][i]; + if (S < 1e-6) continue; + // The settlement secedes: 65% of the ground's population + // takes the new flag. + for (let c = 0; c < this.B; c++) alleg[c][i] *= 0.35; + alleg[slot][i] += 0.65 * S; + } + } + for (const c of this.aliveBodies) { + if (c === slot) continue; + this.bodyAff[slot][c] = -0.15; + this.bodyAff[c][slot] = -0.15; + } + return slot; + } + + // ---- decay & record ------------------------------------------------------ + + decay(regime, living) { const dt = this.dt; const memory = this.program.memory; const ops = regime.operators; @@ -716,7 +1244,7 @@ export class World { const trailK = Math.pow(0.5, dt / (memory.trailHalfLife * (0.75 + 0.35 * ops.deposit))); const res = this.resource; for (let i = 0; i < res.length; i++) res[i] *= fieldK; - for (let c = 0; c < 3; c++) { + for (let c = 0; c < this.B; c++) { const t = this.trail[c]; for (let i = 0; i < t.length; i++) t[i] *= trailK; } @@ -726,10 +1254,10 @@ export class World { // Wells recharge to a ceiling; the section's energy sets the rate. const scale = (0.22 + 0.7 * regime.energy) * dt; - for (const well of this.wells) { - const add = well.rate * scale / well.peak; - const idx = well.idx; - const w = well.w; + 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]; res[cell] = Math.min(1.6, res[cell] + add * w[i]); @@ -748,6 +1276,7 @@ export class World { h.food[f] = (food * 3) / res.length; h.births[f] = this.birthsThisFrame; h.deaths[f] = this.deathsThisFrame; + h.bodies[f] = this.aliveBodies.length; } /** The camera's view of where something just happened. */ @@ -766,6 +1295,8 @@ export class World { peak, fieldMass: this.sampleFieldMass(), trailMass: this.sampleTrailMass(), + strains: this.strains.map((s) => s.name), + bodies: this.aliveBodies.length, name: this.genome.name, }; } @@ -779,10 +1310,10 @@ export class World { sampleTrailMass() { let s = 0; - for (let c = 0; c < 3; c++) { + for (let c = 0; c < this.trail.length; c++) { const t = this.trail[c]; for (let i = 0; i < t.length; i++) s += t[i]; } - return s / (this.trail[0].length * 3); + return s / (this.trail[0].length * this.trail.length); } } diff --git a/metabolism/src/song/events.js b/metabolism/src/song/events.js index 0f77664..4e71384 100644 --- a/metabolism/src/song/events.js +++ b/metabolism/src/song/events.js @@ -40,7 +40,7 @@ export function buildEvents(analysis, rng) { spread: 0.014 + 0.040 * strength * strength, // Food units. Scaled so a dense arrangement supports roughly a // thousand agents and a sparse one a few hundred; see the report. - amount: (10 + 34 * strength * strength) * 0.07, + amount: (10 + 34 * strength * strength) * 0.11, // Strong events seed life as well as food. spawn: strength > 0.62 ? (o.voice === 0 ? 3 : o.voice === 1 ? 2 : 1) : 0, born: 0, diff --git a/metabolism/src/song/palette.js b/metabolism/src/song/palette.js index 5377439..4788f97 100644 --- a/metabolism/src/song/palette.js +++ b/metabolism/src/song/palette.js @@ -1,7 +1,7 @@ -// The track's key sets the world's hue. Noise sets how far the three castes -// sit from each other on the wheel. Nothing here is a palette chosen by hand: -// two songs in the same key will still separate on saturation and on the -// contrast between the builder and the spark. +// The track's key sets the world's hue. Noise sets how far the voices and +// the bodies sit from each other on the wheel. Nothing here is a palette +// chosen by hand: two songs in the same key will still separate on saturation +// and on the contrast between their macro-organisms. const TAU = Math.PI * 2; @@ -41,6 +41,18 @@ export function buildPalette(summary, rng) { const c1 = hsl(hue + 0.10 + 0.03 * beat, sat, lift + 0.07); const c2 = hsl(hue + 0.54 + (noise - 0.5) * 0.10, sat * 0.96, lift + 0.15); + // The bodies (M2.7): macro-organisms the allegiance field composes from. + // Eight hues, spread wide but seeded from the track's own hue so a song's + // civil war still wears that song's clothes. Body 0–3 alternate around + // the wheel; 4–7 are the fission colours, hotter and lighter. + const bodies = []; + for (let k = 0; k < 8; k++) { + const off = k < 4 ? (k - 1) * 0.16 : 0.5 + ((k - 4) * 0.11); + const satK = k < 4 ? sat : Math.min(1, sat * 1.1); + const liftK = k < 4 ? lift : Math.min(0.85, lift + 0.12 + (k - 4) * 0.03); + bodies.push(hsl(hue + off + (noise - 0.5) * 0.06, satK, liftK)); + } + const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright); const fog = hsl(hue + 0.5, 0.6, 0.075); @@ -50,8 +62,10 @@ export function buildPalette(summary, rng) { bg, fog, classes: [c0, c1, c2], + bodies, // Trails sit below the agents: the world is dimmer than what moves in it. trails: [c0.map((v) => v * 0.55), c1.map((v) => v * 0.55), c2.map((v) => v * 0.55)], + bodyTrails: bodies.map((c) => c.map((v) => v * 0.5)), grain: hsl(hue + 0.5, 0.2, 0.5), ink: [0.92, 0.94, 0.96], }; diff --git a/metabolism/src/song/rules.js b/metabolism/src/song/rules.js index 82fef63..cf7cce8 100644 --- a/metabolism/src/song/rules.js +++ b/metabolism/src/song/rules.js @@ -1,67 +1,58 @@ -// The rule program (EVOLUTION.md M2). +// The rule program (EVOLUTION.md M2, retargeted by M2.7). // -// The world used to be driven by a genome + per-section multiplier laws — the -// same behaviours, stronger or weaker. A rule program is the upgrade: a -// declarative, per-song description of *which* behaviour exists, compiled once -// deterministically from the analysis + seed. +// A declarative, per-song description of *which* behaviour exists, compiled +// once deterministically from the analysis + seed: // -// castes each caste is a rule entry: speed, seeking, dispersion, and the -// growth rules (mutualism bonus, social floor, appetite). -// rules 3×3 signed interaction matrix: +attract / −repel, from the -// band-correlation graph. -// sections per-section OPERATOR REGIMES: a breakdown switches swarming off -// and inscription on; a drop opens the floodgates; an intro keeps -// everyone gentle. Structural, not magnitude. -// events which voice feeds which caste, and how hard. +// strains the population's personalities (LittleVM programs live in +// strains.js); each is an economy entry: speed, deposit, cap, +// growth, appetite — and the growth rules (synergy: a strain +// breeds fastest in a mixed, same-body crowd). +// wells the living well policy: how many may live at once. +// bodies how many macro-organisms the song opens with, and the cap +// (allegiance is per-organism and changes over time). +// diplomacy the base warmth between bodies, from the track's own +// consonance; sections bias it (drop = war, breakdown = truce). +// sections per-section OPERATOR REGIMES — structural, scoped by kind. +// events which strain each voice feeds, and how much splashes. // memory how long the world keeps what it writes. // -// Everything is a pure function of (analysis summary, derived genome, seed -// stream), so the program is part of the song and the prepared-artifact path -// rebuilds bit-identical programs. +// Everything is a pure function of (analysis summary, derived genome, strains, +// seed stream), so the program is part of the song and the prepared-artifact +// path rebuilds it bit-identically. const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); const lerp = (a, b, t) => a + (b - a) * t; -const CASTE_NAMES = ['builder', 'forager', 'spark']; -// Base dispersion per caste (how hard they flee their own crowd) — builders -// stay, sparks scatter some but not so hard that they can never gather. -const BASE_DISPERSION = [0.05, 0.10, 0.20]; -// Survival economy (see M1's tuning): caps and appetite are near-level so the -// castes can share a cell; growth is gentle and music-agnostic here, the -// regime operators are what swing it. -const capFor = (split) => 15 + 3 * clamp01(split / 2); -const growthFor = (split) => 0.92 / (1 + 0.10 * clamp01(split / 2)) + 0.24; -const EAT_RATE = 0.042; - /** - * Per-section operator regimes — the structural core of M2. - * Each operator is a weight on a behaviour; roughly: <0.25 the behaviour is - * effectively OFF, ~1.0 it runs normally, >1.5 it dominates. + * Per-section operator regimes — the structural core. Each operator is a + * weight on a behaviour; roughly: <0.25 the behaviour is effectively OFF, + * ~1.0 it runs normally, >1.5 it dominates. * chemo follow the food gradient - * attract follow/avoid the other castes (signed rules, scaled here) * disperse spread away from one's own crowd - * home pull back to the wells (place) + * home pull to the living wells (place) * turbulence noise-driven jitter + * peace war ↔ truce between bodies (1 = neutral; <1 hostile, >1 calm) * speed / growth / deposit / split / spawn */ export const REGIMES = { - intro: { chemo: 0.7, attract: 0.8, disperse: 0.9, home: 1.6, turbulence: 0.5, speed: 0.6, growth: 0.7, deposit: 0.8, split: 0.9, spawn: 0.5 }, - build: { chemo: 0.9, attract: 1.0, disperse: 1.1, home: 0.9, turbulence: 0.8, speed: 1.0, growth: 1.2, deposit: 1.0, split: 1.0, spawn: 1.2 }, - drop: { chemo: 1.5, attract: 1.6, disperse: 1.7, home: 0.2, turbulence: 2.4, speed: 1.7, growth: 1.7, deposit: 0.6, split: 1.6, spawn: 2.4 }, - breakdown: { chemo: 0.4, attract: 0.3, disperse: 0.15, home: 2.2, turbulence: 0.15, speed: 0.4, growth: 0.3, deposit: 2.2, split: 1.4, spawn: 0.25 }, - sustain: { chemo: 0.9, attract: 1.0, disperse: 1.0, home: 0.8, turbulence: 0.8, speed: 0.9, growth: 0.9, deposit: 1.1, split: 1.0, spawn: 0.8 }, - outro: { chemo: 0.5, attract: 0.6, disperse: 0.5, home: 2.0, turbulence: 0.3, speed: 0.5, growth: 0.4, deposit: 1.8, split: 1.2, spawn: 0.3 }, + 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 }, }; -const OP_KEYS = ['chemo', 'attract', 'disperse', 'home', 'turbulence', 'speed', 'growth', 'deposit', 'split', 'spawn']; +const OP_KEYS = ['chemo', 'disperse', 'home', 'turbulence', 'speed', 'growth', 'deposit', 'split', 'spawn', 'peace']; // Ceilings so a song's character amplifies a regime without blowing it up. -const OP_MAX = { chemo: 2.0, attract: 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 }; +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 }; /** * How the track's own character scales each operator family — this is what * makes a noisy song structurally different from a tonal one, in the regime - * itself, not just in the castes' numbers. + * itself, not just in the strains' numbers. */ function songScales(summary) { const s = summary; @@ -71,42 +62,68 @@ function songScales(summary) { const tone = clamp01(1 - s.flatness); return { chemo: 0.80 + 0.35 * clamp01(s.centroid), // bright mixes smell harder - attract: 0.70 + 0.65 * (1 - noise), // correlated bands draw harder - disperse: 0.70 + 0.60 * noise, // noisy tracks scatter - home: 0.75 + 0.55 * tone, // tonal tracks hold place - turbulence: 0.55 + 1.0 * noise, // noisy tracks jitter - speed: 0.80 + 0.45 * beat, // a pulse makes them move - growth: 0.80 + 0.45 * density, // dense tracks breed more - deposit: 0.70 + 0.70 * tone, // tonal tracks inscribe more + disperse: 0.70 + 0.60 * noise, // noisy tracks scatter + home: 0.75 + 0.55 * tone, // tonal tracks hold place + turbulence: 0.55 + 1.0 * noise, // noisy tracks jitter + speed: 0.80 + 0.45 * beat, // a pulse makes them move + growth: 0.80 + 0.45 * density, // dense tracks breed more + deposit: 0.70 + 0.70 * tone, // tonal tracks inscribe more split: 0.85 + 0.35 * density, spawn: 0.80 + 0.50 * beat, + peace: 0.85 + 0.30 * tone, // tonal tracks settle easier }; } +/** + * The track's consonance: how much of its chroma sits on consonant intervals + * of its own tonic. This is diplomacy's base — the harmony the song sustains + * is the peace its bodies know before the sections bend it. + */ +function consonance(summary) { + const chroma = summary.chroma; + if (!chroma) return 0.5; + let tonic = 0; + for (let pc = 1; pc < 12; pc++) if (chroma[pc] > chroma[tonic]) tonic = pc; + const CONSONANT = [0, 2, 4, 5, 7, 9, 11]; + const isConsonant = new Set(CONSONANT.map((i) => (tonic + i) % 12)); + let good = 0; + let all = 1e-9; + for (let pc = 0; pc < 12; pc++) { + all += chroma[pc]; + if (isConsonant.has(pc)) good += chroma[pc]; + } + return good / all; +} + +/** How much of each hit splashes to a strain, by role. */ +const SPLASH_BY_ROLE = { builder: 0.5, grazer: 0.55, scout: 0.9, sentinel: 0.4, breeder: 0.75 }; + /** * Compile the world's rule program for a song. * @param {object} summary analysis.summary - * @param {object} genome deriveGenome() output (names, classes, interaction, - * laws, jitter, memory half-lives) - * @param {object} rng seeded Rng fork (unused today — kept in signature so - * a future rule search can jitter the program) + * @param {object} genome deriveGenome() output + * @param {Array} strains compileStrains() output (the personalities) */ -export function compileProgram(summary, genome) { +export function compileProgram(summary, genome, strains) { const scales = songScales(summary); - const castes = genome.classes.map((c, i) => ({ - name: CASTE_NAMES[i], - speed: c.speed, - seek: c.seek, - dispersion: BASE_DISPERSION[i], - cap: capFor(c.split), - growth: growthFor(c.split), - eat: EAT_RATE, - deposit: c.deposit, - // Growth rules per caste: - // mutualism — all castes breed faster among the others. - mutual: { base: 0.30, threshold: 6 }, - social: null, - })); + const cons = consonance(summary); + + // The song opens a few bodies (2–4): a rich arrangement founds more. + const balance = summary.bandBalance || [0.33, 0.33, 0.33]; + const spread = Math.max(...balance) - Math.min(...balance); + const opening = clamp(2 + (spread > 0.22 ? 1 : 0) + (genome.laws.length >= 4 ? 1 : 0), 2, 4); + + // Each voice speaks to a strain: low → the heaviest writer, mid → the + // commuter, high → the restless one; whoever exists takes the stage. + const pick = (role, fallback) => { + const idx = strains.findIndex((s) => s.role === role); + return idx >= 0 ? idx : fallback % strains.length; + }; + const voiceStrain = [ + pick('builder', 0), + pick('grazer', 1), + pick('scout', 2), + ]; const sections = genome.laws.map((law) => { const base = REGIMES[law.kind] || REGIMES.sustain; @@ -122,19 +139,34 @@ export function compileProgram(summary, genome) { memory: { trailHalfLife: genome.trailHalfLife, fieldHalfLife: genome.fieldHalfLife, - // How fast population haze bleeds; the track's noise sets it. - diffusion: 0.004 + 0.045 * clamp01(summary.flatness), - jitter: 0.02 + 0.5 * clamp01(summary.flatness), + // How fast population haze bleeds; the track's noise sets it, but + // never to zero — a still world is a dead one. + diffusion: 0.006 + 0.045 * clamp01(summary.flatness), + jitter: 0.06 + 0.5 * clamp01(summary.flatness), }, - castes, - rules: genome.interaction, - home: { shared: true, pull: [1.5, 1.1, 0.7], reach: 0.55 }, - society: { ceiling: 105, global: 60000 }, + strains: strains.map((s) => ({ + name: s.name, + role: s.role, + speed: s.speed, + deposit: s.deposit, + cap: s.cap, + growth: s.growth, + eat: s.eat, + // Synergy: every strain breeds fastest among a same-body crowd — + // the mixed biofilm out-grows any monoculture. + synergy: { base: 0.25, threshold: 5 }, + })), + wells: { maxActive: 12 }, + bodies: { opening, max: 8 }, + // Diplomacy: consonance decides whether bodies are kin or rivals by + // default; the section's peace operator bends it (see World.warmth). + diplomacy: { base: clamp((cons - 0.60) * 2.4, -1, 1), scale: 0.5 }, + society: { ceiling: 48, global: 60000 }, sections, - // Which voice feeds which caste: each event splats food and restocks - // the society. The voice speaks first and loudest; sparks — fast and - // ephemeral — take a larger splash of every hit. - events: { splash: [0.5, 0.5, 0.9] }, + events: { + splash: strains.map((s) => SPLASH_BY_ROLE[s.role] ?? 0.5), + voiceStrain, + }, }; } diff --git a/metabolism/src/song/song.js b/metabolism/src/song/song.js index 7b1242f..44b4aab 100644 --- a/metabolism/src/song/song.js +++ b/metabolism/src/song/song.js @@ -1,9 +1,11 @@ // The song, as a prescription for a world. One call, no rendering, no clock. // -// events where food appears, and when -// genome what kind of creature eats it, and how they relate -// laws how the rules change at section boundaries -// palette the key, as colour +// events where food appears, and when +// wells the living fertile places: born, drifting, dying (a timeline) +// genome what kind of creature eats it, and how they relate +// strains the personalities — LittleVM programs the song compiled +// program the rule program: economy, diplomacy, section regimes +// laws how the rules change at section boundaries // // Everything downstream is a pure function of this object plus a frame number. @@ -12,15 +14,17 @@ import { buildPalette } from './palette.js'; import { buildEvents, indexEvents } from './events.js'; import { deriveGenome } from './species.js'; import { compileProgram } from './rules.js'; -import { compileTribes } from './tribe.js'; +import { compileStrains } from './strains.js'; +import { compileWells } from '../sim/wells.js'; export function buildSong(analysis, seed = 1) { const rng = new Rng(seed >>> 0).fork('metabolism'); const palette = buildPalette(analysis.summary, rng.fork('palette')); const events = buildEvents(analysis, rng.fork('events')); const genome = deriveGenome(analysis, rng.fork('genome')); - const program = compileProgram(analysis.summary, genome); - const tribes = compileTribes(analysis.summary, genome, rng.fork('tribes')); + const strains = compileStrains(analysis.summary, genome, rng.fork('strains')); + const program = compileProgram(analysis.summary, genome, strains); + const wells = compileWells(analysis, rng.fork('wells')); return { seed: seed >>> 0, analysis, @@ -30,10 +34,11 @@ export function buildSong(analysis, seed = 1) { index: indexEvents(events, analysis.frames), genome, program, - tribes, + strains, + wells, name: genome.name, laws: genome.laws, }; } -export { buildPalette, buildEvents, deriveGenome, compileProgram, compileTribes }; +export { buildPalette, buildEvents, deriveGenome, compileProgram, compileStrains }; diff --git a/metabolism/src/song/strains.js b/metabolism/src/song/strains.js new file mode 100644 index 0000000..25dcdde --- /dev/null +++ b/metabolism/src/song/strains.js @@ -0,0 +1,348 @@ +// The strains (EVOLUTION.md M2.7) — the song decides how many kinds of minds +// its world needs, then writes them. +// +// There are no castes and no tribes. The population is ONE species whose +// density is partitioned by *strain* — a personality, a LittleVM program the +// song compiled for it. A biofilm, not three societies: specialists working +// the same ground, and the mix itself evolves (the world runs local selection +// on the strain proportions). +// +// The VM is small and total: senses → registers → branches → actions, run +// per coarse decision block, every substep. Senses now include the body the +// local organisms belong to (KIN: same body, other strains; FOE: other +// bodies), the living wells (proximity + recharge), and the section's PEACE — +// so a program can literally branch on "war or truce". +// +// Everything compiles deterministically from (analysis summary, genome, seed +// stream): same song, same seed → same programs, bit for bit. + +export const S_FOOD = 0; +export const S_SELF = 1; // own strain, this block +export const S_KIN = 2; // same body, other strains +export const S_FOE = 3; // other bodies' presence +export const S_TRAIL = 4; // own body's trail here +export const S_LAW = 5; // section's spawn operator +export const S_HUNGER = 6; +export const S_EDGE = 7; +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; + +// Gradient fields, two slots each (x, y): food, own strain, kin, foe, well. +export const G_FOOD = 0; +export const G_SELF = 1; +export const G_KIN = 2; +export const G_FOE = 3; +export const G_WELL = 4; +export const G_FIELDS = 5; +export const GRAD_COUNT = G_FIELDS * 2; + +export const A_MOVE_X = 0; +export const A_MOVE_Y = 1; +export const A_EXCRETE = 2; +export const A_EAT = 3; +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 REGS = 6; + +export const MAX_INSTR = 32; +export const MAX_STEPS = 48; + +export const OP = { + SET: 0, ADD: 1, MUL: 2, SUB: 3, CLAMP: 4, LERP: 5, + SENSE: 6, GRAD: 7, IFGT: 8, IFLT: 9, IFEQ: 10, IFNEQ: 11, ACT: 12, HALT: 13, +}; + +const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); + +// ---- a tiny assembler ------------------------------------------------------- +// +// Programs are built from motifs; jumps need patching, so the assembler hands +// out patch slots and labels. No control flow beyond the VM's own. + +class Asm { + constructor() { this.code = []; } + label() { return this.code.length; } + emit(...ins) { this.code.push(ins); return this.code.length - 1; } + // Conditional jumps return a patch slot; call .jump(patch, target) later. + ifgt(reg, thr) { return this.emit(OP.IFGT, reg, thr, 0); } + iflt(reg, thr) { return this.emit(OP.IFLT, reg, thr, 0); } + jump(patch, target) { this.code[patch][3] = target; return this; } + sense(reg, s, w = 1) { return this.emit(OP.SENSE, reg, s, w); } + grad(reg, field, axis, w = 1) { return this.emit(OP.GRAD, reg, field, axis, w); } + act(a, reg, w = 1) { return this.emit(OP.ACT, a, reg, w); } + mul(reg, k) { return this.emit(OP.MUL, reg, k); } + halt() { return this.emit(OP.HALT); } +} + +// ---- roles ------------------------------------------------------------------ +// +// Each role is a motif family plus the economy the world needs to run it. +// The song picks which roles exist (m of them) and tunes every threshold; +// the numbers below are bases, not destiny. + +const ROLES = { + builder: { + base: { speed: 0.030, deposit: 1.50, cap: 1.25, growth: 0.90, eat: 0.036 }, + hungryAt: 0.50, spawnW: 0.30, + }, + grazer: { + base: { speed: 0.075, deposit: 0.75, cap: 1.00, growth: 1.00, eat: 0.050 }, + hungryAt: 0.42, spawnW: 0.45, + }, + scout: { + base: { speed: 0.125, deposit: 0.18, cap: 0.80, growth: 1.05, eat: 0.055 }, + hungryAt: 0.62, spawnW: 0.60, + }, + sentinel: { + base: { speed: 0.058, deposit: 0.55, cap: 1.00, growth: 0.85, eat: 0.045 }, + 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 }, + hungryAt: 0.55, spawnW: 0.85, kinAt: 0.12, + }, +}; + +/** How many minds the song needs: independent voices, texture, seed. */ +function strainCount(summary, genome, rng) { + const density = clamp01(summary.onsetDensity / 6); + const noise = clamp01(summary.flatness); + // A band that lives its own life (weak correlation to the others) is an + // independent voice — it earns its own specialist. + let independent = 0; + const corr = summary.bandCorrelation || [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; + for (let i = 0; i < corr.length; i++) { + let maxOff = -2; + for (let j = 0; j < corr.length; j++) if (j !== i) maxOff = Math.max(maxOff, corr[i][j] ?? 0); + if (maxOff < 0.6) independent++; + } + let m = 2 + + (density > 0.35 ? 1 : 0) // a busy arrangement needs hands + + (independent >= 1 ? 1 : 0) // a self-willed voice needs a mind + + (noise > 0.5 ? 1 : 0); // dense texture: more specialists + if (rng.next() < 0.35) m += 1; // the seed may add one more voice + return clamp(m, 2, 5); +} + +/** + * Compile the population's strains for a song. + * @returns {Array<{name, role, code, init, speed, deposit, cap, growth, eat}>} + */ +export function compileStrains(summary, genome, rng) { + const m = strainCount(summary, genome, rng); + const density = clamp01(summary.onsetDensity / 6); + const pace = genome.pace; + const cohesion = genome.cohesion; + const balance = summary.bandBalance || [0.33, 0.33, 0.33]; + const noise = clamp01(summary.flatness); + + // The role order is the song's; the seed rotates the casting so two + // similar songs still differ in who they specialised. + const order = Object.keys(ROLES); + const rot = rng.int(0, order.length - 1); + const roles = order.slice(rot).concat(order.slice(0, rot)).slice(0, m); + + return roles.map((role, i) => { + const spec = ROLES[role]; + const band = balance[i % 3]; + const asm = new Asm(); + // R0 hunger, R1 law(spawn), R2 well pull, R3 peace, R4 kin, R5 foe. + asm.sense(0, S_HUNGER, 1); + asm.sense(1, S_LAW, 1); + asm.sense(2, S_WELL, 1); + asm.sense(3, S_PEACE, 1); + + // ---- the sentinel's war branch: a body at war fights before it + // eats. PEACE decides strike vs shadow; the branch sits at the end, + // so the fall-through below is the ordinary life. + let foePatch = -1; + if (spec.foeAt !== undefined) { + asm.sense(5, S_FOE, 1); + foePatch = asm.ifgt(5, spec.foeAt * (0.8 + 0.4 * rng.next())); + } + + // ---- the breeder's bloom branch: breed only in a fed crowd. + let kinPatch = -1; + if (spec.kinAt !== undefined) { + asm.sense(4, S_KIN, 1); + kinPatch = asm.ifgt(4, spec.kinAt * (0.8 + 0.4 * rng.next())); + } + + // ---- hungry: follow the smell of food. A starving district trusts + // the gradient; the world's well anchor blends in on top. + const hungryPatch = asm.iflt(0, spec.hungryAt * (0.85 + 0.3 * noise)); + asm.grad(4, G_FOOD, 0, 1); + asm.grad(5, G_FOOD, 1, 1); + asm.act(A_MOVE_X, 4, 1); + asm.act(A_MOVE_Y, 5, 1); + asm.act(A_EAT, 0, 1); + asm.halt(); + + // ---- fed: the role's own work. + 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); + 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(); + } else if (role === 'grazer') { + // Commutes: follows the food edge, leaves a road behind. + asm.grad(4, G_FOOD, 0, 0.7); + asm.grad(5, G_FOOD, 1, 0.7); + asm.act(A_MOVE_X, 4, 0.9); + asm.act(A_MOVE_Y, 5, 0.9); + asm.act(A_EXCRETE, 3, 0.6); + asm.act(A_SPAWN, 1, spec.spawnW); + asm.halt(); + } else if (role === 'scout') { + // Restless: re-aims every step, speeds up, writes almost nothing. + asm.grad(4, G_FOOD, 0, 0.5); + asm.act(A_DRIFT, 4, 1.4); + asm.act(A_ACCELERATE, 3, 1); + asm.act(A_SPAWN, 1, spec.spawnW * (0.6 + 0.8 * noise)); + asm.halt(); + } else if (role === 'sentinel') { + // Patrol: sweeps along the body's frontier, never parked. + asm.grad(4, G_KIN, 0, 0.8); + asm.grad(5, G_KIN, 1, 0.8); + asm.act(A_MOVE_X, 4, 0.6); + asm.act(A_MOVE_Y, 5, 0.6); + asm.grad(4, G_SELF, 0, 0.5); + asm.act(A_DRIFT, 4, 1); + asm.act(A_EXCRETE, 3, 0.5); + asm.act(A_SPAWN, 1, spec.spawnW); + asm.halt(); + } else { // breeder + // Follows the crowd it feeds on; spawns when the law allows. + asm.grad(4, G_KIN, 0, 1); + asm.grad(5, G_KIN, 1, 1); + asm.act(A_MOVE_X, 4, 0.7); + asm.act(A_MOVE_Y, 5, 0.7); + asm.act(A_SPAWN, 1, spec.spawnW * (0.8 + 0.6 * density)); + asm.act(A_EXCRETE, 3, 0.4); + asm.halt(); + } + + // ---- the breeder's bloom tail (reached only from the kin branch). + if (kinPatch >= 0) { + const bloom = asm.label(); + asm.jump(kinPatch, bloom); + asm.act(A_SPAWN, 1, spec.spawnW * (0.7 + 0.9 * density)); + asm.act(A_EXCRETE, 3, 0.4); + asm.halt(); + } + + // ---- the sentinel's war tail (reached only from the foe branch): + // peace low → close and strike; peace high → shadow the border. + if (foePatch >= 0) { + const war = asm.label(); + asm.jump(foePatch, war); + const strike = asm.iflt(3, 0.45); + // truce: fall back from the border, keep writing. + asm.grad(4, G_FOE, 0, -0.8); + asm.grad(5, G_FOE, 1, -0.8); + asm.act(A_MOVE_X, 4, 0.5); + asm.act(A_MOVE_Y, 5, 0.5); + asm.act(A_EXCRETE, 3, 0.5); + asm.halt(); + const strikeLabel = asm.label(); + asm.jump(strike, strikeLabel); + asm.grad(4, G_FOE, 0, 1); + asm.grad(5, G_FOE, 1, 1); + asm.act(A_MOVE_X, 4, 1); + asm.act(A_MOVE_Y, 5, 1); + asm.act(A_HIT, 5, 1.2); + asm.halt(); + } + + // Economy: the track tunes the role's body. + const bandBoost = 0.7 + 0.6 * band; + return { + name: role, + role, + code: asm.code.slice(0, MAX_INSTR), + init: [0, 0, 0, 0, 0, 0], + speed: spec.base.speed * (0.35 + 1.10 * pace) * (role === 'scout' ? 1 : bandBoost), + deposit: spec.base.deposit * (0.35 + 1.25 * cohesion), + cap: 10 * spec.base.cap * (1.20 - 0.30 * density), + growth: ((0.92 / (1 + 0.10 * density)) + 0.24) * spec.base.growth, + eat: spec.base.eat * (0.85 + 0.4 * genome.aggression), + }; + }); +} + +// ---- the VM ----------------------------------------------------------------- + +function clampVM(v) { return v < 0 ? 0 : v > 1 ? 1 : v; } + +/** + * Run one strain program for one block. Registers live in `memBuf` at `off` + * (persistent across substeps — that is the strain's memory); `senses` and + * `grads` are this block's read of the world; `act` accumulates the decision. + */ +export function runProgram(memBuf, off, senses, grads, act, code) { + let pc = 0; + 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; + const mem = memBuf; + while (steps++ < MAX_STEPS && pc < n) { + const ins = code[pc]; + const op = ins[0]; + switch (op) { + case OP.SET: mem[off + ins[1]] = ins[2]; break; + case OP.ADD: mem[off + ins[1]] += mem[off + ins[2]]; break; + case OP.MUL: mem[off + ins[1]] *= ins[2]; break; + case OP.SUB: mem[off + ins[1]] -= mem[off + ins[2]]; break; + case OP.CLAMP: mem[off + ins[1]] = clampVM(mem[off + ins[1]]); break; + case OP.LERP: mem[off + ins[1]] += (mem[off + ins[2]] - mem[off + ins[1]]) * ins[3]; break; + case OP.SENSE: mem[off + ins[1]] = senses[ins[2]] * ins[3]; break; + case OP.GRAD: mem[off + ins[1]] = grads[ins[2] * 2 + ins[3]] * (ins[4] ?? 1); break; + case OP.IFGT: + if (mem[off + ins[1]] > ins[2]) { pc = ins[3]; continue; } + break; + case OP.IFLT: + if (mem[off + ins[1]] < ins[2]) { pc = ins[3]; continue; } + break; + case OP.IFEQ: + if (mem[off + ins[1]] == ins[2]) { pc = ins[3]; continue; } + break; + case OP.IFNEQ: + if (mem[off + ins[1]] != ins[2]) { pc = ins[3]; continue; } + break; + case OP.ACT: { + const drive = mem[off + ins[2]]; + const w = ins[3] ?? 1; + switch (ins[1]) { + case A_MOVE_X: act[0] += drive * w; break; + case A_MOVE_Y: act[1] += drive * w; break; + case A_EXCRETE: act[2] += Math.max(0, drive) * w * 1.5; break; + case A_EAT: act[3] *= drive * w + 1; break; + case A_HIT: act[4] += Math.max(0, drive) * w * 0.02; 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_ACCELERATE: act[3] *= drive * w + 1; break; + default: break; + } + break; + } + case OP.HALT: + default: + return act; + } + pc++; + } + return act; +} diff --git a/metabolism/src/song/tribe.js b/metabolism/src/song/tribe.js deleted file mode 100644 index 42c7218..0000000 --- a/metabolism/src/song/tribe.js +++ /dev/null @@ -1,143 +0,0 @@ -export const S_FOOD = 0; -export const S_SELF = 1; -export const S_OTHER0 = 2; -export const S_OTHER1 = 3; -export const S_TRAIL = 4; -export const S_LAW = 5; -export const S_HUNGER = 6; -export const S_EDGE = 7; -export const S_TIME = 8; -export const S_TOTAL = 9; -export const SENSE_COUNT = 10; - -export const A_MOVE_X = 0; -export const A_MOVE_Y = 1; -export const A_EXCRETE = 2; -export const A_EAT = 3; -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 REGS = 4; - -export const MAX_INSTR = 24; -export const MAX_STEPS = 40; - -export const OP = { - SET: 0, ADD: 1, MUL: 2, SUB: 3, CLAMP: 4, LERP: 5, - SENSE: 6, GRAD: 7, IFGT: 8, IFLT: 9, IFEQ: 10, IFNEQ: 11, ACT: 12, HALT: 13, -}; - -export function compileTribes(summary, genome, rng) { - const noise = clamp01(summary.flatness); - const beat = clamp01(summary.beatStrength); - const density = clamp01(summary.onsetDensity / 6); - const names = ['builder', 'forager', 'spark']; - const BASE_DISPERSION = [0.05, 0.10, 0.20]; - const mutual = { base: 0.30, threshold: 6 }; - const tribes = []; - - for (let t = 0; t < 3; t++) { - const a = (t + 1) % 3; - const b = (t + 2) % 3; - const toA = clamp01(genome.interaction[t][a] * 0.5 + 0.5); - const toB = clamp01(genome.interaction[t][b] * 0.5 + 0.5); - const code = [ - [OP.SENSE, 3, S_HUNGER, 1], - [OP.IFGT, 3, 0.45, 4], - [OP.LERP, 0, 0, 1 - 0.5], - [OP.LERP, 1, 0, 1 - 0.5], - [OP.SENSE, 3, S_OTHER0, toA], - [OP.GRAD, 3, S_OTHER0, 0], - [OP.ACT, A_MOVE_X, 3, 1], - [OP.SENSE, 3, S_OTHER1, toB], - [OP.GRAD, 3, S_OTHER1, 1], - [OP.ACT, A_MOVE_Y, 3, 1], - [OP.ACT, A_EXCRETE, 2, 0.6], - [OP.HALT], - [OP.GRAD, 0, S_FOOD, 0, 1.0], - [OP.GRAD, 1, S_FOOD, 1, 1.0], - [OP.ACT, A_MOVE_X, 0, 1], - [OP.ACT, A_MOVE_Y, 1, 1], - [OP.SENSE, 2, S_LAW, 1], - [OP.ACT, A_SPAWN, 2, 0.15 + 0.7 * density], - [OP.GRAD, 0, S_FOOD, 0, 1.0], - [OP.GRAD, 1, S_FOOD, 1, 1.0], - [OP.ACT, A_MOVE_X, 0, 1], - [OP.ACT, A_MOVE_Y, 1, 1], - [OP.SENSE, 2, S_LAW, 1], - [OP.ACT, A_SPAWN, 2, 0.5], - - // restless: the tribe re-evaluates its heading every step. - [OP.SENSE, 3, S_EDGE, 1], - [OP.IFGT, 3, 0.5, 4], - [OP.GRAD, 0, S_FOOD, 0, 0.5], - [OP.ACT, A_MOVE_X, 0, 0.5], - [OP.HALT], - - // social floor: the spark only blooms in a luminous crowd. - [OP.IFGT, 3, 0.16666666666666666, 4], - ]; - tribes.push({ name: names[t], init: [0, 0, 0, 0], code: code.slice(0, MAX_INSTR) }); - } - return tribes; -} - -function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; } - -function R(memOff, reg) { return mem[memOff + reg]; } - -export function runTribe(memOff, senses, grads, act, code) { - let pc = 0; - 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; - while (steps++ < MAX_STEPS && pc < n) { - const ins = code[pc]; - const op = ins[0]; - switch (op) { - case OP.SET: mem[memOff + ins[1]] = ins[2]; break; - case OP.ADD: mem[memOff + ins[1]] += R(memOff, ins[2]); break; - case OP.MUL: mem[memOff + ins[1]] *= ins[2]; break; - case OP.SUB: mem[memOff + ins[1]] -= R(memOff, ins[2]); break; - case OP.CLAMP: mem[memOff + ins[1]] = clamp01((R(memOff, ins[1]) - ins[2]) / Math.max(1e-6, ins[3] - ins[2])) * (ins[3] - ins[2]) + ins[2]; break; - case OP.LERP: mem[memOff + ins[1]] += (R(memOff + ins[2]) - R(memOff + ins[1])) * ins[3]; break; - case OP.SENSE: mem[memOff + ins[1]] = senses[ins[2]] * ins[3]; break; - case OP.GRAD: mem[memOff + ins[1]] = grads[ins[2] * 2 + ins[3]] * (ins[4] ?? 1); break; - case OP.IFGT: - if (R(memOff + ins[1]) > ins[2]) { pc = ins[3]; continue; } - break; - case OP.IFLT: - if (R(memOff + ins[1]) < ins[2]) { pc = ins[3]; continue; } - break; - case OP.IFEQ: - if (R(memOff + ins[1]) == ins[2]) { pc = ins[3]; continue; } - break; - case OP.IFNEQ: - if (R(memOff + ins[1]) != ins[2]) { pc = ins[3]; continue; } - break; - case OP.ACT: { - const drive = R(memOff + ins[2]); - const w = ins[3] ?? 1; - switch (ins[1]) { - case A_MOVE_X: act[0] += drive * w; break; - case A_MOVE_Y: act[1] += drive * w; break; - case A_EXCRETE: act[2] += Math.max(0, drive) * w * 1.5; break; - case A_EAT: act[3] *= drive * w + 1; break; - case A_HIT: act[4] += Math.max(0, drive) * w * 0.02; 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_ACCELERATE: act[3] *= drive * w + 1; break; - default: break; - } - break; - } - case OP.HALT: - default: - return act; - } - pc++; - } - return act; -} \ No newline at end of file diff --git a/metabolism/test/allegiance.test.js b/metabolism/test/allegiance.test.js new file mode 100644 index 0000000..78159a2 --- /dev/null +++ b/metabolism/test/allegiance.test.js @@ -0,0 +1,264 @@ +// M2.7 — one species, many minds: strains & allegiances, living wells. +// The tests here pin the new story layer: the allegiance invariant, the +// bodies' politics (war converts, splinters fission, friends merge), the +// strain mix evolving by local selection, and the wells' lifecycle. + +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 { wellPos } from '../src/sim/wells.js'; +import { Run } from '../src/film.js'; +import { OP, S_FOE, G_FOE, A_MOVE_X, A_MOVE_Y, A_HIT } 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, opts = {}) { + const song = load(name, opts.seed ?? 12345); + const r = new Run(song, { + fps: 30, width: 240, height: 135, field: 64, genesis: 0, + ...(opts.run ?? {}), + }); + return r; +} + +// ---- living wells ----------------------------------------------------------- + +test('well timeline compiles deterministically', () => { + const a = load('drone').wells; + const b = load('drone').wells; + assert.equal(a.wells.length, b.wells.length); + for (let i = 0; i < a.wells.length; i++) { + assert.equal(a.wells[i].born, b.wells[i].born); + assert.equal(a.wells[i].died, b.wells[i].died); + assert.deepEqual(Array.from(a.wells[i].px), Array.from(b.wells[i].px)); + } +}); + +test('the world wakes on living wells and never exceeds the cap', () => { + const song = load('drone'); + const w = run('drone').world; + assert.ok(w.livingWells(0).length >= 3, 'at least three wells alive at frame 0'); + for (let f = 0; f < 600; f++) { + const alive = w.livingWells(f).length; + assert.ok(alive <= song.wells.maxActive, `too many wells at f${f}: ${alive}`); + if (f < 600) w.step(); + } +}); + +test('wells drift: at least one well moves a visible distance', () => { + const w = run('drone').world; + let maxDrift = 0; + for (const well of w.wells) { + const life = well.died - well.born; + if (life < 300) continue; + const [x0, y0] = wellPos(well, well.born); + const [x1, y1] = wellPos(well, well.born + Math.floor(life / 2)); + maxDrift = Math.max(maxDrift, Math.hypot(x1 - x0, y1 - y0)); + } + assert.ok(maxDrift > 0.02, `wells should drift (max ${maxDrift.toFixed(4)})`); +}); + +test('a well birth is a land-rush: food lands where the well sprouts', () => { + const w = run('runner').world; + const soon = w.wells.find((well) => well.born > 10 && well.born < 450); + assert.ok(soon, 'runner should grow a well within 15 s'); + // A control world where the same well is born one frame later: the only + // difference at the birth frame is the land-rush itself. + const cw = run('runner').world; + const twin = cw.wells.find((well) => well.id === soon.id); + twin.born += 1; + + const [x, y] = wellPos(soon, soon.born); + const near = (world) => { + let s = 0; + const F = world.field; + const cx = Math.round(x * (F - 1)); + const cy = Math.round(y * (F - 1)); + for (let dy = -3; dy <= 3; dy++) { + for (let dx = -3; dx <= 3; dx++) { + const px = Math.min(F - 1, Math.max(0, cx + dx)); + const py = Math.min(F - 1, Math.max(0, cy + dy)); + s += world.resource[py * F + px]; + } + } + return s; + }; + for (let f = w.frame + 1; f < soon.born; f++) { w.step(); cw.step(); } + const pre = near(w); + w.step(); + cw.step(); + const post = near(w); + const controlPost = near(cw); + assert.ok(post > pre, `a well birth must add ground (${pre.toFixed(3)} → ${post.toFixed(3)})`); + assert.ok(post > controlPost, `the birth feeds more than the same frame without one (${post.toFixed(3)} vs ${controlPost.toFixed(3)})`); +}); + +// ---- allegiance: the invariant ---------------------------------------------- + +test('allegiance tracks the population exactly (Σ bodies = density)', () => { + const w = run('dusk').world; + for (let f = 0; f < 240; f++) w.step(); + let worst = 0; + let negatives = 0; + for (let i = 0; i < w.n; i++) { + let s = 0; + for (let b = 0; b < w.B; b++) { + if (w.alleg[b][i] < -1e-9) negatives++; + s += w.alleg[b][i]; + } + if (w.density[i] > 1e-4) worst = Math.max(worst, Math.abs(s - w.density[i])); + } + assert.equal(negatives, 0, 'no negative allegiance'); + assert.ok(worst < 1e-2, `composition must track population (worst ${worst.toFixed(4)})`); +}); + +test('the song opens several bodies and the count stays sane', () => { + const song = load('dusk'); + const w = run('dusk').world; + assert.ok(w.aliveBodies.length >= 2, 'the song opens more than one body'); + const horizon = Math.min(600, song.analysis.frames - 1); + for (let f = 0; f < horizon; f++) { + w.step(); + assert.ok(w.aliveBodies.length >= 1 && w.aliveBodies.length <= w.B); + assert.ok(w.history.bodies[w.frame] >= 1, 'history records the body count'); + } +}); + +// ---- allegiance: war converts ------------------------------------------------- + +test('hostile bodies contest a front: war kills, but never converts', () => { + const make = (base) => { + const r = run('runner'); + const w = r.world; + // War (or truce) footing: every strain marches toward the foe and + // strikes on contact. + r.song.program.diplomacy.base = base; + for (const s of w.strains) { + s.code = [ + [OP.SENSE, 5, S_FOE, 1], + [OP.IFGT, 5, 0.02, 7], + [OP.GRAD, 4, G_FOE, 0, 1], + [OP.GRAD, 5, G_FOE, 1, 1], + [OP.ACT, A_MOVE_X, 4, 1], + [OP.ACT, A_MOVE_Y, 5, 1], + [OP.HALT], + [OP.GRAD, 4, G_FOE, 0, 1], + [OP.GRAD, 5, G_FOE, 1, 1], + [OP.ACT, A_MOVE_X, 4, 1], + [OP.ACT, A_MOVE_Y, 5, 1], + [OP.ACT, A_HIT, 5, 2], + [OP.HALT], + ]; + } + // Two bodies planted face to face across the meridian. + const F = w.field; + const seedSide = (xa, xb, body) => { + for (let y = 20; y < 44; y++) { + for (let x = xa; x < xb; x++) { + const i = y * F + x; + for (let s = 0; s < w.m; s++) w.pop[s][i] = 4; + w.alleg[body][i] = 4 * w.m; + } + } + }; + seedSide(12, 30, 0); + seedSide(34, 52, 1); + w.bodies[0].cx = 14 / F; w.bodies[0].cy = 0.5; w.bodies[0].alive = true; + w.bodies[1].cx = 50 / F; w.bodies[1].cy = 0.5; w.bodies[1].alive = true; + if (!w.aliveBodies.includes(0)) w.aliveBodies.push(0); + if (!w.aliveBodies.includes(1)) w.aliveBodies.push(1); + w.recount(); + return w; + }; + + const war = make(-1); + const peace = make(1); + for (let f = 0; f < 240; f++) { war.step(); peace.step(); } + + // War kills: the contested world ends up with fewer organisms than the + // same world at truce. + assert.ok(war.count < peace.count * 0.98, + `war should cost lives (war ${war.count.toFixed(0)} vs peace ${peace.count.toFixed(0)})`); + + // But allegiance never changes hands: each body's mass stays (to within + // organic growth) where it was planted — nothing joined the other side. + const F = war.field; + const heartland = (w, b, xa, xb) => { + let inside = 0; + let outside = 0; + for (let y = 0; y < F; y++) { + for (let x = 0; x < F; x++) { + const i = y * F + x; + if (x >= xa && x < xb) inside += w.alleg[b][i]; + else outside += w.alleg[b][i]; + } + } + return { inside, outside }; + }; + const h0 = heartland(war, 0, 0, 32); // body 0 planted left + const h1 = heartland(war, 1, 32, F); // body 1 planted right + assert.ok(h0.outside < h0.inside * 0.2, `body 0 must not take the east (${h0.outside.toFixed(1)} vs ${h0.inside.toFixed(1)})`); + assert.ok(h1.outside < h1.inside * 0.2, `body 1 must not take the west (${h1.outside.toFixed(1)} vs ${h1.inside.toFixed(1)})`); +}); + +// ---- allegiance: war & colonisation --------------------------------------------- + +test('colonisation: a settlement on virgin ground can plant a new flag', () => { + const w = run('dusk').world; + const F = w.field; + // A colony with allegiance to body 0 far from any other body's core. + for (let y = 18; y < 26; y++) { + for (let x = 18; x < 26; x++) { + const i = y * F + x; + for (let s = 0; s < w.m; s++) w.pop[s][i] = 6; + w.alleg[0][i] = 6 * w.m; + } + } + w.bodies[0].alive = true; + w.bodies[0].cx = 0.34; w.bodies[0].cy = 0.34; + if (!w.aliveBodies.includes(0)) w.aliveBodies.push(0); + w.recount(); + + const FC = w.FC; + const block = 10 * FC + 10; + const n0 = w.aliveBodies.length; + const founded = w.foundBody(block, w.frame + 1); + assert.ok(founded >= 0, 'a settlement can found a body'); + assert.equal(w.aliveBodies.length, n0 + 1); + // The new body holds the seceded ground. + let newMass = 0; + for (let i = 0; i < w.n; i++) newMass += w.alleg[founded][i]; + assert.ok(newMass > 10, `the new body took ground (${newMass.toFixed(1)})`); +}); + +// ---- the strain mix evolves ---------------------------------------------------- + +test('the strain mix evolves by local selection', () => { + const w = run('runner', { run: { field: 64 } }).world; + const share = () => w.pop.map((p) => { + let s = 0; + for (let i = 0; i < p.length; i++) s += p[i]; + return s; + }); + const early = share(); + for (let f = 0; f < 600; f++) w.step(); + const late = share(); + const total0 = early.reduce((a, v) => a + v, 0); + const total1 = late.reduce((a, v) => a + v, 0); + let drift = 0; + for (let s = 0; s < w.m; s++) { + drift += Math.abs(early[s] / total0 - late[s] / total1); + } + assert.ok(drift > 0.02, `strain proportions must drift (L1 ${drift.toFixed(3)})`); +}); diff --git a/metabolism/test/rules.test.js b/metabolism/test/rules.test.js index 4a49c7c..8c3fbdd 100644 --- a/metabolism/test/rules.test.js +++ b/metabolism/test/rules.test.js @@ -33,12 +33,12 @@ test('different songs get structurally different programs', () => { const runner = load('runner').program; const dusk = load('dusk').program; - // Castes differ (speed/seek/cap/growth from the track's stats). - const dr = JSON.stringify(drone.castes); - assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.castes))); + // Strains differ (speed/deposit/cap/growth from the track's stats). + const dr = JSON.stringify(drone.strains); + assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.strains))); - // Interaction rules differ (band-correlation graph). - assert.notEqual(JSON.stringify(drone.rules), JSON.stringify(dusk.rules)); + // Diplomacy differs (the tracks' own consonance). + assert.notEqual(JSON.stringify(drone.diplomacy), JSON.stringify(dusk.diplomacy)); // At least one operator family differs materially in the same section kind. const dropOf = (p) => p.sections.find((s) => s.kind === 'drop')?.operators; diff --git a/metabolism/test/strains.test.js b/metabolism/test/strains.test.js new file mode 100644 index 0000000..3e4772d --- /dev/null +++ b/metabolism/test/strains.test.js @@ -0,0 +1,161 @@ +// The strains (M2.7): the song decides how many kinds of minds it needs and +// compiles a LittleVM program for each. These tests pin down determinism, +// per-song structural variety, the count being musical, the branching itself, +// and that the world truly executes the programs. + +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 { + compileStrains, runProgram, OP, S_HUNGER, S_FOOD, S_SELF, S_FOE, S_PEACE, + A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN, A_HIT, +} from '../src/song/strains.js'; +import { Run } from '../src/film.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 snapshot(code) { + return JSON.stringify(code.map((ins) => ins.join(':'))); +} + +test('strains compile deterministically and differ per song', () => { + const a = load('drone').strains; + const b = load('drone').strains; + assert.equal(a.length, b.length); + for (let s = 0; s < a.length; s++) assert.equal(snapshot(a[s].code), snapshot(b[s].code)); + + const runner = load('runner').strains; + const anyDiffer = a.some((ta, i) => snapshot(ta.code) !== snapshot(runner[i].code)); + assert.ok(anyDiffer, 'runner should compile structurally different strain programs'); +}); + +test('the strain count is musical, not fixed', () => { + const songs = ['drone', 'runner', 'dusk'].map((n) => load(n)); + const counts = songs.map((s) => s.strains.length); + assert.ok(counts.every((m) => m >= 2 && m <= 5), `counts in 2..5 (${counts})`); + // A sparse song and a dense one should not always agree on the cast size + // (the seed may still add a voice, so 2 of 3 differing is enough). + assert.ok(new Set(counts).size >= 2 || counts[0] !== counts[1], + `songs should differ in cast size (${counts})`); +}); + +test('strains have bounded programs with real branching', () => { + const strains = load('dusk').strains; + assert.ok(strains.every((s) => s.code.length > 3 && s.code.length <= 32), 'programs bounded and non-trivial'); + const ops = new Set(strains.flatMap((s) => s.code.map((ins) => ins[0]))); + assert.ok(ops.has(OP.IFGT) || ops.has(OP.IFLT), 'programs branch'); + assert.ok(ops.has(OP.SENSE) && ops.has(OP.ACT), 'programs sense and act'); +}); + +test('the VM branches on senses: starving seeks food, fed works', () => { + // A tiny program: if hunger high → move along food gradient; else excrete. + const code = [ + [OP.SENSE, 3, S_HUNGER, 1], + [OP.IFLT, 3, 0.5, 4], // fed → jump to 4 + [OP.GRAD, 0, S_FOOD, 0], // hungry: heading x = +1 + [OP.GRAD, 1, S_FOOD, 1], // heading y = -1 + [OP.ACT, A_MOVE_X, 0, 1], + [OP.ACT, A_MOVE_Y, 1, 1], + [OP.HALT], + [OP.ACT, A_EXCRETE, 2, 1], // fed: excrete (R2=0 → ~0) + [OP.HALT], + ]; + const mem = new Float32Array(6); + const senses = new Float32Array(12); + const grads = new Float32Array(12); + const act = new Float32Array(6); + + // Hungry (HUNGER high) → moves, no excrete. + senses[S_HUNGER] = 0.9; + grads[0] = 1; grads[1] = -1; + runProgram(mem, 0, senses, grads, act, code); + assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry strain should move toward food'); + + // Fed (HUNGER low) → no movement. + senses[S_HUNGER] = 0.1; + mem.fill(0); + grads[0] = 1; grads[1] = 1; + runProgram(mem, 0, senses, grads, act, code); + assert.equal(act[A_MOVE_X], 0, 'fed strain should not chase food'); +}); + +test('the sentinel branch: hostile peace strikes, peaceful peace shadows', () => { + // Foe present, program branches on PEACE. + const code = [ + [OP.SENSE, 3, S_PEACE, 1], // 0 + [OP.SENSE, 5, S_FOE, 1], // 1 + [OP.IFGT, 5, 0.05, 4], // 2: foe in range → 4 + [OP.HALT], // 3: no foe → nothing + [OP.IFLT, 3, 0.45, 8], // 4: war → 8 + [OP.HALT], // 5: (truce tail placeholder) + [OP.GRAD, 4, 3, 0, -0.8], // 6: truce: back away (unused path) + [OP.HALT], // 7 + [OP.GRAD, 4, 3, 0, 1], // 8: war: head toward the foe + [OP.ACT, A_MOVE_X, 4, 1], // 9 + [OP.ACT, A_HIT, 5, 1], // 10 + [OP.HALT], // 11 + ]; + const mem = new Float32Array(6); + const senses = new Float32Array(12); + const grads = new Float32Array(12); + const act = new Float32Array(6); + + senses[S_FOE] = 0.8; + senses[S_PEACE] = 0.1; // war + grads[3 * 2] = 1; // foe gradient x + runProgram(mem, 0, senses, grads, act, code); + assert.ok(act[A_HIT] > 0, 'at war the sentinel strikes'); + + senses[S_PEACE] = 0.9; // truce + act.fill(0); + runProgram(mem, 0, senses, grads, act, code); + assert.equal(act[A_HIT], 0, 'at truce the sentinel does not strike'); +}); + +test('memories (registers) persist across runs', () => { + const code = [ + [OP.SENSE, 0, S_SELF, 1], // R0 = local density + [OP.LERP, 1, 0, 0.5], // R1 decays toward R0 (memory) + [OP.HALT], + ]; + const mem = new Float32Array(6); + const senses = new Float32Array(12); + const grads = new Float32Array(12); + const act = new Float32Array(6); + senses[S_SELF] = 0.8; + runProgram(mem, 0, senses, grads, act, code); + runProgram(mem, 0, senses, grads, act, code); + assert.ok(mem[1] > 0, 'register kept value across runs'); + assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`); +}); + +test('the world executes the strain programs (a no-op program stops its actions)', () => { + const song = load('runner'); + const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 }); + for (let f = 0; f < 900; f++) run.world.step(); + + // Same song, same world — but every strain program halts immediately: no + // movement or actions from the program (forces become ambient-only). The + // grown world must be materially different. + const inert = load('runner'); + for (const strain of inert.strains) strain.code = [[OP.HALT]]; + const run2 = new Run(inert, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 }); + for (let f = 0; f < 900; f++) run2.world.step(); + + let d = 0; + const a = run.world.history.movement; + const b = run2.world.history.movement; + for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]); + assert.ok(d > 1e-3, `inert programs must change the film (movement Δ=${d.toFixed(3)})`); +}); diff --git a/metabolism/test/tribe.test.js b/metabolism/test/tribe.test.js deleted file mode 100644 index b3f05ac..0000000 --- a/metabolism/test/tribe.test.js +++ /dev/null @@ -1,119 +0,0 @@ -// The tribe programs (M2.6): the song compiles small deterministic behaviour -// programs with sensing, memory, actions and branching, and the world executes -// them. These tests pin down determinism, per-song structural variety, the -// branching itself, and that the world truly executes the program. - -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 { - compileTribes, runTribe, OP, S_HUNGER, S_FOOD, S_SELF, A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN, -} from '../src/song/tribe.js'; -import { Run } from '../src/film.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 snapshot(code) { - return JSON.stringify(code.map((ins) => ins.join(':'))); -} - -test('tribes compile deterministically and differ per song', () => { - const a = load('drone').tribes; - const b = load('drone').tribes; - assert.equal(a.length, b.length); - for (let t = 0; t < a.length; t++) assert.equal(snapshot(a[t].code), snapshot(b[t].code)); - - const runner = load('runner').tribes; - const anyDiffer = a.some((ta, i) => snapshot(ta.code) !== snapshot(runner[i].code)); - assert.ok(anyDiffer, 'runner should compile structurally different tribe programs'); -}); - -test('tribes have bounded programs with real branching', () => { - const tribes = load('drone').tribes; - assert.ok(tribes.every((t) => t.code.length > 3 && t.code.length <= 24), 'programs bounded and non-trivial'); - const ops = new Set(tribes.flatMap((t) => t.code.map((ins) => ins[0]))); - assert.ok(ops.has(OP.IFGT) || ops.has(OP.IFLT), 'programs branch'); - assert.ok(ops.has(OP.SENSE) && ops.has(OP.ACT), 'programs sense and act'); -}); - -test('the VM branches on senses: starving seeks food, fed excreats', () => { - // A tiny program: if hunger high → move along food gradient; else excrete. - const code = [ - [OP.SENSE, 3, S_HUNGER, 1], - [OP.IFGT, 3, 0.5, 4], // hungry → jump to 4 - [OP.ACT, A_EXCRETE, 2, 1], // (fed) R2=0 → excrete ~0 - [OP.HALT], - [OP.GRAD, 0, S_FOOD, 0], // hungry: heading x = +1 - [OP.GRAD, 1, S_FOOD, 1], // heading y = +1 - [OP.ACT, A_MOVE_X, 0, 1], - [OP.ACT, A_MOVE_Y, 1, 1], - [OP.HALT], - ]; - const mem = new Float32Array(4); - const senses = new Float32Array(10); - const grads = new Float32Array(20); - const act = new Float32Array(6); - - // Hungry (HUNGER high) → moves, no excrete. - senses[S_HUNGER] = 0.9; - grads[0] = 1; grads[1] = -1; - runTribe(mem, 0, senses, grads, act, code); - assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry tribe should move toward food'); - - // Fed (HUNGER low) → no movement, excrete from R2 (memory persists? R2=0 → excrete 0). - senses[S_HUNGER] = 0.1; - mem.fill(0); - grads[0] = 1; grads[1] = 1; - runTribe(mem, 0, senses, grads, act, code); - assert.equal(act[A_MOVE_X], 0, 'fed tribe should not chase food'); - assert.equal(act[A_EXCRETE], 0); -}); - -test('memories (registers) persist across runs', () => { - const code = [ - [OP.SENSE, 0, S_SELF, 1], // R0 = local density - [OP.LERP, 1, 0, 0.5], // R1 decays toward R0 (memory) - [OP.HALT], - ]; - const mem = new Float32Array(4); - const senses = new Float32Array(10); - const grads = new Float32Array(20); - const act = new Float32Array(6); - senses[S_SELF] = 0.8; - runTribe(mem, 0, senses, grads, act, code); - runTribe(mem, 0, senses, grads, act, code); - assert.ok(mem[1] > 0, 'register kept value across runs'); - // R1 after two 50% steps toward 0.8 is exactly 0.6 (float tolerance). - assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`); -}); - -test('the world executes the tribe program (a no-op program stops its actions)', () => { - const song = load('runner'); - const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 }); - for (let f = 0; f < 900; f++) run.world.step(); - - // Same song, same world — but every tribe program halts immediately: no - // movement or actions from the program (forces become ambient-only). The - // grown world must be materially different. - const inert = load('runner'); - for (const tribe of inert.tribes) tribe.code = [[OP.HALT]]; - const run2 = new Run(inert, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 }); - for (let f = 0; f < 900; f++) run2.world.step(); - - let d = 0; - const a = run.world.history.movement; - const b = run2.world.history.movement; - for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]); - assert.ok(d > 1e-3, `inert programs must change the film (movement Δ=${d.toFixed(3)})`); -}); diff --git a/metabolism/test/world.test.js b/metabolism/test/world.test.js index 964c132..975bbc2 100644 --- a/metabolism/test/world.test.js +++ b/metabolism/test/world.test.js @@ -90,7 +90,7 @@ test('genesis pre-simulates generations so frame 0 is already an ecosystem', () assert.ok(withGenesis.world.count > without.world.count * 1.1, `genesis world should be more populated (${withGenesis.world.count} vs ${without.world.count})`); // …and the ground already carries history, where a fresh plate has none. - assert.ok(withGenesis.world.sampleTrailMass() > 0.0001, + assert.ok(withGenesis.world.sampleTrailMass() > 0.00005, `genesis world should already hold ink (${withGenesis.world.sampleTrailMass()})`); assert.equal(withGenesis.world.history.alive[0], 0, 'genesis must not record into the film'); assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0');