Metabolism M2.7: one species, many minds — strains (song-compiled LittleVMs), bodies with fixed allegiance, living wells, food-limited economy
This commit is contained in:
+28
-31
@@ -171,25 +171,22 @@ district. The film shows specialisation *evolving* inside one species.
|
|||||||
|
|
||||||
**Allegiance — the body.** Every organism also belongs to a macro-organism —
|
**Allegiance — the body.** Every organism also belongs to a macro-organism —
|
||||||
its **body** — a polity of mixed strains that builds, defends and expands as
|
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
|
one. Allegiance is a *field*: the population partitioned by body, advected
|
||||||
frozen-in with the organisms as they move. And it changes:
|
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;
|
||||||
- **conversion** — at a contested front the stronger body captures ground and
|
there is no defection and no merger — territories stay legible because their
|
||||||
converts the locals;
|
colours never homogenise. What keeps more than one body alive is
|
||||||
- **defection** — when a body's presence collapses (its food gone, its ground
|
**colonisation**: a settlement on ground far from every body core, blessed by
|
||||||
lost), its members flip to the neighbouring body;
|
a strong onset, may plant a NEW flag (a fresh colour) — rare, and it grows
|
||||||
- **fission** — a coherent splinter cut off from its body, blessed by a strong
|
the cast rather than reshuffling it.
|
||||||
onset, is founded as a new body with a new colour;
|
|
||||||
- **merger** — two bodies whose members fully intermix under a cooperative
|
|
||||||
regime become one.
|
|
||||||
|
|
||||||
Bodies are few — the song opens 2–4, capped at ~8 so colour stays legible —
|
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 they are the drama. The film is the story of macro-organisms **fighting
|
||||||
and cooperating**: skirmish lines at fronts, truces in consonant sections,
|
and holding ground**: skirmish lines at fronts where the loser bleeds, truces
|
||||||
symbiosis where one body's waste is another's food. The song sets the terms
|
in consonant sections, symbiosis where one body's waste is another's food.
|
||||||
of diplomacy: consonant, harmonically locked material breeds symbiosis;
|
The song sets the terms of diplomacy: consonant, harmonically locked material
|
||||||
dissonant, decorrelated material breeds rivalry; drops are expansion wars;
|
breeds peace; dissonant, decorrelated material breeds rivalry; drops are
|
||||||
breakdowns are truces and mutual building.
|
expansion wars; breakdowns are truces and mutual building.
|
||||||
|
|
||||||
A species name like "fast tonal weaver" falls out of the genome, so the report
|
A species name like "fast tonal weaver" falls out of the genome, so the report
|
||||||
can talk about the world and not the track.
|
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),
|
per-song rule program: castes as rule entries (mutualism, social floors),
|
||||||
interaction rules, per-section operator regimes (structural section
|
interaction rules, per-section operator regimes (structural section
|
||||||
changes), the event→response mapping, and a shared carrying capacity.
|
changes), the event→response mapping, and a shared carrying capacity.
|
||||||
- **M2.7 — one species, many minds** *(designed, not yet built — see
|
- **M2.7 — one species, many minds** ✔ built: tribes/castes dissolved into a
|
||||||
EVOLUTION.md)*: tribes/castes are dissolved into a single population field
|
single population field with two live properties — a **strain mix** of
|
||||||
with two live properties — a **strain mix** of song-compiled LittleVM
|
song-compiled LittleVM personalities evolving by local selection, and an
|
||||||
personalities evolving by local selection, and an **allegiance field**
|
**allegiance field** whose macro-organisms ("bodies") hold territory, wage
|
||||||
whose macro-organisms ("bodies") convert, defect, fission and merge,
|
war (which kills, never converts) and colonise virgin ground — on top of
|
||||||
fighting or cooperating as the song's harmony and sections dictate — on top
|
the living well timeline. The world's economy is food-limited: colonies
|
||||||
of the living well timeline.
|
bloom on wells, starve off bare ground, and must keep moving to keep
|
||||||
|
eating.
|
||||||
- **M3 — lens** the camera travels (track / reveal / establish) and reserves
|
- **M3 — lens** the camera travels (track / reveal / establish) and reserves
|
||||||
cuts for musical punctuation.
|
cuts for musical punctuation.
|
||||||
- **M4 — film** mux frames + PCM to video.
|
- **M4 — film** mux frames + PCM to video.
|
||||||
|
|
||||||
## Current state, honestly
|
## Current state, honestly
|
||||||
|
|
||||||
The world is alive and the songs diverge on the metrics, but it is not yet a
|
The world is alive, the songs diverge on the metrics, and the population is
|
||||||
*film*: the renderer is a software-framebuffer proof, the reference tracks are
|
now one species of many minds: strains compiled by the song, bodies that hold
|
||||||
synthetic test signals, the ground the population stands on is still furniture
|
territory and war over living wells. It is not yet a *film*: the reference
|
||||||
(four permanent springs), and the population itself is still three fixed
|
tracks are synthetic test signals, the lens still prefers the biggest knot to
|
||||||
castes instead of one evolving mix of minds. M2.7 (EVOLUTION.md) fixes both:
|
a traveling shot (M3), and muxing is waiting (M4). The allegiance layer is
|
||||||
wells that are born, drift and die; strains — song-compiled LittleVM
|
deliberately rigid — bodies never switch sides — so territories read; the
|
||||||
personalities — whose mix adapts by local selection; and allegiances —
|
drama currently comes from war, colonisation and the wells' own lives.
|
||||||
macro-organisms that fight, cooperate, and change membership over time.
|
|
||||||
+42
-51
@@ -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
|
the instruction grammar (more branch shapes, richer memory, more action
|
||||||
archetypes) is the natural next step for this layer.
|
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
|
Built — with one correction agreed mid-build, after watching the first films:
|
||||||
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.
|
|
||||||
|
|
||||||
**Wells keep the correction from the previous draft** — small, weak, many
|
> **Allegiance does not change sides.** The original draft had fronts
|
||||||
(8–16), born where a pitch class enters and holds, drifting with its harmony,
|
> converting, collapsed bodies leaking their members, splinters seceding and
|
||||||
dying when it leaves, all on a precompiled deterministic timeline — because
|
> friendly bodies merging. On screen that read as one thing: every colour
|
||||||
wells are where bodies collide: everyone needs them, so a well is the natural
|
> blurring into every other within a minute — ugly, and it erased the very
|
||||||
front, its birth a land-rush, its death a retreat.
|
> 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 —
|
- **strains** (`src/song/strains.js`) — the song decides how many minds it
|
||||||
*m* strains from the number of independent voices the mix resolves into,
|
needs (2–5, from onset density, an independent spectral voice, texture
|
||||||
bounded by arrangement depth, jittered by the seed — and compiles a
|
noisiness, seed jitter) and compiles a LittleVM program per strain from
|
||||||
**LittleVM program per strain** from the grammar: senses (food, density,
|
motif roles: builder, grazer, scout, sentinel, breeder. Senses include the
|
||||||
trail, law, hunger, rim, well proximity and liveliness, body presence),
|
body's presence (KIN/FOE), the living wells, and the section's PEACE — a
|
||||||
registers as memory, real branching, actions (move, graze, excrete, hit,
|
program can branch on war or truce. The world runs local selection:
|
||||||
spawn, follow, patrol). Density is partitioned by strain; each strain
|
strains that thrive where they stand propagate.
|
||||||
advects by its own program. And the mix **evolves by local selection**:
|
- **living wells** (`src/sim/wells.js`) — a precompiled deterministic
|
||||||
strains that eat and grow where they stand propagate, proportions shift
|
timeline of small, weak, numerous wells (4+ at open, ≤12 alive): born where
|
||||||
district by district — specialisation is visible and develops over the film.
|
a pitch class enters and holds, drifting along its harmony's compass,
|
||||||
- **allegiance (the body).** A composition field over bodies (the song opens
|
starving when it leaves. A birth is a land-rush; a death a retreat.
|
||||||
2–4; a cap of ~8 keeps colour legible), advected frozen-in with the
|
- **bodies & diplomacy** — allegiance is a concentration field advected
|
||||||
population flux. Same body = **cooperation**: shared trails, reinforcement,
|
frozen-in with the population flux (Σ bodies = density exactly, a test).
|
||||||
complementary strains building one structure. Different bodies = **contest**
|
Diplomacy = the track's consonance bent by the section's peace operator;
|
||||||
where presences overlap: hit-actions spend density to push the other body's
|
hostile fronts bleed the loser (casualties, never conversion). Colonisation
|
||||||
ground and convert it, scaled by the song's harmony — consonant, locked
|
plants new bodies on blessed virgin ground.
|
||||||
voices breed truce and symbiosis; dissonant, decorrelated voices breed
|
- **a food-limited economy** — the old world was accidentally
|
||||||
skirmish and siege. Allegiance changes by rule, never by fiat:
|
cap-limited: the gate floor let life grow on bare ground, so the dish
|
||||||
- **conversion** — a won front converts the locals it overruns;
|
filled to a uniform saturated carpet and stopped moving. Now the gate is
|
||||||
- **defection** — a body whose presence collapses (starved, outflanked)
|
nearly shut without food and starvation dissolves colonies off bare
|
||||||
releases its members to the neighbouring body;
|
ground: the population tracks its wells, blooms on them, and must keep
|
||||||
- **fission** — a coherent splinter cut off from its body, hit by a strong
|
moving to keep eating.
|
||||||
onset, founds a new body with a new colour;
|
|
||||||
- **merger** — two bodies whose members fully intermix under a cooperative
|
|
||||||
regime become one.
|
|
||||||
|
|
||||||
**The song sets the diplomacy.** Consonance and the section regimes map to
|
*Outcome:* 39 tests green, including the allegiance invariant (no negatives,
|
||||||
war and peace: drops are expansion wars, breakdowns are truces and mutual
|
composition = population), war-kills-not-converts (a war world ends poorer
|
||||||
building, an outro is a settling of borders. The body-to-body relations come
|
than its truce twin, with each body's mass still in its own heartland),
|
||||||
from the music, not a constant.
|
colonisation, strain-mix drift, well determinism/drift/land-rush. Realtime
|
||||||
|
holds at 640×360 (~12 ms/frame sim+render). Songs still diverge ≫ seeds on
|
||||||
*Exit: two songs with the same pulse but different arrangement depth compile
|
the fingerprint (classMix now sorted — the seed permutes the casting, the
|
||||||
different strain counts and different diplomacies; at least one allegiance
|
song decides its shape).
|
||||||
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.*
|
|
||||||
|
|
||||||
### M3 — the lens: travel, don't cut
|
### M3 — the lens: travel, don't cut
|
||||||
Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a
|
Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a
|
||||||
|
|||||||
@@ -96,10 +96,12 @@ export class Camera {
|
|||||||
// map — every culture and the roads between them — is in frame, and
|
// map — every culture and the roads between them — is in frame, and
|
||||||
// only dip toward a knot on the rare close beat.
|
// only dip toward a knot on the rare close beat.
|
||||||
const coverage = this.activityCoverage();
|
const coverage = this.activityCoverage();
|
||||||
let zoom = 0.92 + (1 - coverage) * 0.55;
|
let zoom = 0.92 + (1 - coverage) * 0.35;
|
||||||
if (this.shot % 3 === 2) zoom *= 0.86;
|
if (this.shot % 3 === 2) zoom *= 0.88;
|
||||||
else if (this.shot % 3 === 1) zoom *= 1.12;
|
else if (this.shot % 3 === 1) zoom *= 1.08;
|
||||||
zoom = clamp(zoom, 0.62, 1.5);
|
// 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);
|
const focus = this.pickFocus(zoom);
|
||||||
this.target = { x: focus.x, y: focus.y };
|
this.target = { x: focus.x, y: focus.y };
|
||||||
|
|||||||
@@ -199,8 +199,8 @@ export function fingerprint(world) {
|
|||||||
const cell = F / g;
|
const cell = F / g;
|
||||||
const pop = new Float32Array(g * g);
|
const pop = new Float32Array(g * g);
|
||||||
const trail = new Float32Array(g * g);
|
const trail = new Float32Array(g * g);
|
||||||
const balance = [0, 0, 0];
|
const balance = new Array(world.pop.length).fill(0);
|
||||||
for (let c = 0; c < 3; c++) {
|
for (let c = 0; c < world.pop.length; c++) {
|
||||||
const p = world.pop[c];
|
const p = world.pop[c];
|
||||||
for (let fy = 0; fy < g; fy++) {
|
for (let fy = 0; fy < g; fy++) {
|
||||||
for (let fx = 0; fx < g; fx++) {
|
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];
|
for (let i = 0; i < pp.length; i++) tot += pp[i];
|
||||||
balance[c] = tot;
|
balance[c] = tot;
|
||||||
}
|
}
|
||||||
for (let c = 0; c < 3; c++) {
|
for (let c = 0; c < world.trail.length; c++) {
|
||||||
const t = world.trail[c];
|
const t = world.trail[c];
|
||||||
for (let fy = 0; fy < g; fy++) {
|
for (let fy = 0; fy < g; fy++) {
|
||||||
for (let fx = 0; fx < g; fx++) {
|
for (let fx = 0; fx < g; fx++) {
|
||||||
@@ -246,15 +246,19 @@ export function fingerprint(world) {
|
|||||||
maxP = Math.max(maxP, pop[i]);
|
maxP = Math.max(maxP, pop[i]);
|
||||||
if (trail[i] > 1.5) trailFill++;
|
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.
|
// Cluster: compared to what a uniform full plate would hold.
|
||||||
const uniform = total / n;
|
const uniform = total / n;
|
||||||
cluster = uniform > 0 ? Math.min(1, maxP / (uniform * 18)) : 0;
|
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 {
|
return {
|
||||||
occupancy: Number((occupancy / n).toFixed(3)),
|
occupancy: Number((occupancy / n).toFixed(3)),
|
||||||
cluster: Number(cluster.toFixed(2)),
|
cluster: Number(cluster.toFixed(2)),
|
||||||
trailFill: Number((trailFill / n).toFixed(3)),
|
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) +
|
let d = Math.abs(a.occupancy - b.occupancy) +
|
||||||
Math.abs(a.cluster - b.cluster) +
|
Math.abs(a.cluster - b.cluster) +
|
||||||
Math.abs(a.trailFill - b.trailFill);
|
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));
|
return Number(d.toFixed(3));
|
||||||
}
|
}
|
||||||
@@ -204,19 +204,18 @@ export function composeTrail(world, palette, exposure = 2.2) {
|
|||||||
const F = world.field;
|
const F = world.field;
|
||||||
const n = F * F;
|
const n = F * F;
|
||||||
const sharp = new Float32Array(n * 3);
|
const sharp = new Float32Array(n * 3);
|
||||||
const t0 = world.trail[0];
|
const trails = world.trail;
|
||||||
const t1 = world.trail[1];
|
const cols = palette.bodyTrails ?? palette.bodies ?? palette.trails;
|
||||||
const t2 = world.trail[2];
|
for (let b = 0; b < trails.length; b++) {
|
||||||
const c0 = palette.trails[0];
|
const t = trails[b];
|
||||||
const c1 = palette.trails[1];
|
const c = cols[b % cols.length];
|
||||||
const c2 = palette.trails[2];
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const a = t0[i];
|
const v = t[i];
|
||||||
const b = t1[i];
|
if (v === 0) continue;
|
||||||
const c = t2[i];
|
sharp[i * 3] += v * c[0];
|
||||||
sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0];
|
sharp[i * 3 + 1] += v * c[1];
|
||||||
sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1];
|
sharp[i * 3 + 2] += v * c[2];
|
||||||
sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2];
|
}
|
||||||
}
|
}
|
||||||
const wide = boxBlur(sharp, F, 2);
|
const wide = boxBlur(sharp, F, 2);
|
||||||
const tight = boxBlur(sharp, F, 1);
|
const tight = boxBlur(sharp, F, 1);
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { Bitmap } from './bitmap.js';
|
|||||||
const PER_SPECKS = 2.2;
|
const PER_SPECKS = 2.2;
|
||||||
const MAX_SPECKS = 60000;
|
const MAX_SPECKS = 60000;
|
||||||
// Density glow: how fast a cell's population turns into brightness.
|
// Density glow: how fast a cell's population turns into brightness.
|
||||||
const DENSITY_K = 0.16;
|
const DENSITY_K = 0.11;
|
||||||
const DENSITY_EXPOSURE = 3.2;
|
const DENSITY_EXPOSURE = 1.25;
|
||||||
|
|
||||||
export class Renderer {
|
export class Renderer {
|
||||||
constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) {
|
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);
|
if (this.F !== F) this.allocate(F);
|
||||||
const n = F * F;
|
const n = F * F;
|
||||||
const sharp = this.sharp;
|
const sharp = this.sharp;
|
||||||
const t0 = world.trail[0];
|
const trails = world.trail;
|
||||||
const t1 = world.trail[1];
|
const cols = palette.bodyTrails ?? palette.bodies ?? palette.trails;
|
||||||
const t2 = world.trail[2];
|
|
||||||
const c0 = palette.trails[0];
|
|
||||||
const c1 = palette.trails[1];
|
|
||||||
const c2 = palette.trails[2];
|
|
||||||
const fog = palette.fog;
|
const fog = palette.fog;
|
||||||
const res = world.resource;
|
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++) {
|
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;
|
const f = res[i] * 0.09;
|
||||||
sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0] + f * fog[0];
|
sharp[i * 3] += f * fog[0];
|
||||||
sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1] + f * fog[1];
|
sharp[i * 3 + 1] += f * fog[1];
|
||||||
sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2] + f * fog[2];
|
sharp[i * 3 + 2] += f * fog[2];
|
||||||
}
|
}
|
||||||
boxBlur(sharp, this.tmp, this.tight, F, 1);
|
boxBlur(sharp, this.tmp, this.tight, F, 1);
|
||||||
boxBlur(sharp, this.tmp, this.wide, F, 3);
|
boxBlur(sharp, this.tmp, this.wide, F, 3);
|
||||||
@@ -84,24 +89,42 @@ export class Renderer {
|
|||||||
return out;
|
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) {
|
density(world, palette) {
|
||||||
const F = world.field;
|
const F = world.field;
|
||||||
if (this.F !== F) this.allocate(F);
|
if (this.F !== F) this.allocate(F);
|
||||||
const n = F * F;
|
const n = F * F;
|
||||||
const pop = world.pop;
|
const alleg = world.alleg;
|
||||||
const col = palette.classes;
|
const dens = world.density;
|
||||||
|
const col = palette.bodies ?? palette.classes;
|
||||||
const out = this.densityRGBA;
|
const out = this.densityRGBA;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const a0 = 1 - Math.exp(-pop[0][i] * DENSITY_K);
|
const d = dens[i];
|
||||||
const a1 = 1 - Math.exp(-pop[1][i] * DENSITY_K);
|
if (d < 1e-6) {
|
||||||
const a2 = 1 - Math.exp(-pop[2][i] * DENSITY_K);
|
out[i * 4] = 0;
|
||||||
const r = (a0 * col[0][0] + a1 * col[1][0] + a2 * col[2][0]) * DENSITY_EXPOSURE;
|
out[i * 4 + 1] = 0;
|
||||||
const g = (a0 * col[0][1] + a1 * col[1][1] + a2 * col[2][1]) * DENSITY_EXPOSURE;
|
out[i * 4 + 2] = 0;
|
||||||
const b = (a0 * col[0][2] + a1 * col[1][2] + a2 * col[2][2]) * DENSITY_EXPOSURE;
|
out[i * 4 + 3] = 255;
|
||||||
out[i * 4] = Math.round(255 * (1 - Math.exp(-r)));
|
continue;
|
||||||
out[i * 4 + 1] = Math.round(255 * (1 - Math.exp(-g)));
|
}
|
||||||
out[i * 4 + 2] = Math.round(255 * (1 - Math.exp(-b)));
|
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;
|
out[i * 4 + 3] = 255;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -133,16 +156,13 @@ export class Renderer {
|
|||||||
const scale = this.w * cam.zoom;
|
const scale = this.w * cam.zoom;
|
||||||
const halfW = this.w / 2;
|
const halfW = this.w / 2;
|
||||||
const halfH = this.h / 2;
|
const halfH = this.h / 2;
|
||||||
const col = palette.classes;
|
const col = palette.bodies ?? palette.classes;
|
||||||
|
const alleg = world.alleg;
|
||||||
const d = bmp.data;
|
const d = bmp.data;
|
||||||
let budget = MAX_SPECKS;
|
let budget = MAX_SPECKS;
|
||||||
|
|
||||||
for (let c = 0; c < 3; c++) {
|
const p = world.density;
|
||||||
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 cx = cam.cx;
|
const cx = cam.cx;
|
||||||
const cy = cam.cy;
|
const cy = cam.cy;
|
||||||
for (let y = 0; y < F && budget > 0; y++) {
|
for (let y = 0; y < F && budget > 0; y++) {
|
||||||
@@ -150,16 +170,34 @@ export class Renderer {
|
|||||||
const sy = (wy - cy) * scale + halfH;
|
const sy = (wy - cy) * scale + halfH;
|
||||||
if (sy < -2 || sy > this.h + 2) continue;
|
if (sy < -2 || sy > this.h + 2) continue;
|
||||||
for (let x = 0; x < F && budget > 0; x++) {
|
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;
|
if (v < 1.2) continue;
|
||||||
const wx = (x + 0.5) / F;
|
const wx = (x + 0.5) / F;
|
||||||
const sx = (wx - cx) * scale + halfW;
|
const sx = (wx - cx) * scale + halfW;
|
||||||
if (sx < -2 || sx > this.w + 2) continue;
|
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 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 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++) {
|
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 s2 = (Math.imul(seed ^ (seed >>> 13), 1274126177) ^ 2246822519) >>> 0;
|
||||||
const px = sx + ((s2 >>> 24) / 255 - 0.5) * 1.8;
|
const px = sx + ((s2 >>> 24) / 255 - 0.5) * 1.8;
|
||||||
const py = sy + (((s2 >>> 16) & 255) / 255 - 0.5) * 1.8;
|
const py = sy + (((s2 >>> 16) & 255) / 255 - 0.5) * 1.8;
|
||||||
|
|||||||
+229
-64
@@ -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
|
// The old wells were furniture: 4–5 permanent rich springs that set the map
|
||||||
// as geography nowhere. Wells fix that. They are placed by the track's own
|
// in the first ten seconds and never moved. These wells are small, weak and
|
||||||
// harmony — one well per strong pitch class, on a ring — so the chord is the
|
// many (8–16 active), and they are *characters* in the film:
|
||||||
// map. The mix decides which wells are rich: bass-heavy tracks feed low
|
|
||||||
// classes, bright ones feed the upper ring.
|
|
||||||
//
|
//
|
||||||
// The wells are permanent. That is the point: a place the audience can learn,
|
// birth a pitch class enters the music and HOLDS → a well sprouts where
|
||||||
// return to, and see change. Events still land food elsewhere on the song's
|
// its events already landed (a land-rush);
|
||||||
// grid; the wells are the slow layer underneath.
|
// drift a well wanders with its harmony — it leans along the smoothed
|
||||||
|
// dominant-pitch path and breathes on slow per-well orbits;
|
||||||
import { TAU } from '../song/palette.js';
|
// 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);
|
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
||||||
|
|
||||||
/** Circular distance between pitch classes, in semitones. */
|
const MAX_ACTIVE = 12; // alive at once — many, but never a carpet
|
||||||
function pcDistance(a, b) {
|
const MIN_OPENING = 4; // the world always wakes with at least this many
|
||||||
const d = Math.abs(a - b) % 12;
|
const PATH_STEP = 2; // path sampled every 2 frames
|
||||||
return Math.min(d, 12 - d);
|
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) {
|
/** Circular smoothing: mean unit vector, then atan2 — no angle wrap artefacts. */
|
||||||
const order = Array.from({ length: 12 }, (_, i) => i)
|
function smoothAngle(angleAt, frames, half) {
|
||||||
.sort((a, b) => (chroma[b] ?? 0) - (chroma[a] ?? 0) || a - b);
|
const cos = new Float32Array(frames);
|
||||||
const picked = [];
|
const sin = new Float32Array(frames);
|
||||||
for (const pc of order) {
|
for (let f = 0; f < frames; f++) {
|
||||||
if (picked.every((p) => pcDistance(p, pc) >= 2)) picked.push(pc);
|
const a = angleAt(f);
|
||||||
if (picked.length >= 5) break;
|
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
|
const out = new Float32Array(frames);
|
||||||
// largest gaps with quiet wells so composition never collapses to a line.
|
for (let f = 0; f < frames; f++) {
|
||||||
while (picked.length < 4) {
|
let sx = 0;
|
||||||
const sorted = [...picked].sort((a, b) => a - b);
|
let sy = 0;
|
||||||
let gapAt = 0;
|
let n = 0;
|
||||||
let gap = -1;
|
for (let d = -half; d <= half; d++) {
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
const i = clamp(f + d, 0, frames - 1);
|
||||||
const a = sorted[i];
|
sx += cos[i];
|
||||||
const b = sorted[(i + 1) % sorted.length];
|
sy += sin[i];
|
||||||
const d = ((b - a + 12) % 12) || 12;
|
n++;
|
||||||
if (d > gap) {
|
|
||||||
gap = d;
|
|
||||||
gapAt = a + Math.floor(d / 2);
|
|
||||||
}
|
}
|
||||||
|
out[f] = (sx * sx + sy * sy) > 1e-9 ? Math.atan2(sy / n, sx / n) : -1;
|
||||||
}
|
}
|
||||||
picked.push(((gapAt % 12) + 12) % 12);
|
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 picked.slice(0, 5);
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} summary analysis.summary
|
* Compile the well timeline for a song.
|
||||||
* @param {number} field grid size
|
* @param {object} analysis the analysis table (fps, frames, dominantHz, flatness, onsets, summary)
|
||||||
* @param {number} [sigmaScale] tightens the fertile patches (macro worlds want
|
* @param {object} rng seeded fork
|
||||||
* food localized so colonies deplete barriers between them)
|
* @returns {{ wells: Array, maxActive: number }}
|
||||||
* @returns {Array<{x:number,y:number,r:number,rate:number,pc:number,idx:Int32Array,w:Float32Array}>}
|
* 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) {
|
export function compileWells(analysis, rng) {
|
||||||
const chroma = summary.chroma || new Array(12).fill(0.5);
|
const fps = analysis.fps;
|
||||||
const pcs = pickPitchClasses(chroma);
|
const frames = analysis.frames;
|
||||||
const balance = summary.bandBalance || [0.33, 0.33, 0.33];
|
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 value = clamp(chroma[pc] ?? 0.4, 0.05, 1);
|
||||||
const angle = (pc / 12) * TAU + 0.10 * Math.sin(i * 2.3);
|
const angle = (pc / 12) * TAU + rng.range(-0.10, 0.10);
|
||||||
// Two rings so the world is not a necklace: alternate radii.
|
const ring = (wells.length % 2 === 0 ? 0.28 : 0.36) + rng.range(-0.02, 0.02);
|
||||||
const ring = i % 2 === 0 ? 0.28 : 0.36;
|
// Small and weak: tight kernel, modest recharge — the same total
|
||||||
const radius = ring + 0.03 * Math.sin(i * 1.7);
|
// fertility budget as the old permanent springs, spread thin.
|
||||||
const sigma = (0.050 + 0.040 * value) * sigmaScale;
|
const rate = (0.55 + 1.45 * value) * (0.7 + 0.6 * balance[wells.length % 3]) * (forced ? 1.2 : 1);
|
||||||
const rate = (0.38 + 1.15 * value) * (0.7 + 0.6 * balance[i % 3]);
|
const sigma = 0.032 + 0.016 * value;
|
||||||
|
const w = {
|
||||||
const cells = buildKernel(field, 0.5 + Math.cos(angle) * radius, 0.5 + Math.sin(angle) * radius, sigma, field);
|
id: nextId++,
|
||||||
return {
|
|
||||||
pc,
|
pc,
|
||||||
x: 0.5 + Math.cos(angle) * radius,
|
born: f,
|
||||||
y: 0.5 + Math.sin(angle) * radius,
|
died: Number.POSITIVE_INFINITY,
|
||||||
r: sigma,
|
pathStart: f,
|
||||||
|
pathStep: PATH_STEP,
|
||||||
|
px: null,
|
||||||
|
py: null,
|
||||||
rate,
|
rate,
|
||||||
idx: cells.idx,
|
sigma,
|
||||||
w: cells.w,
|
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) {
|
/** Position of a well at a frame (clamped to its life). */
|
||||||
const radiusPx = Math.ceil(sigma * scale * 2.6);
|
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 cx = Math.round(x * (F - 1));
|
||||||
const cy = Math.round(y * (F - 1));
|
const cy = Math.round(y * (F - 1));
|
||||||
const sig = Math.max(1.2, sigma * F);
|
const sig = Math.max(1.2, sigma * F);
|
||||||
|
|||||||
+858
-327
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ export function buildEvents(analysis, rng) {
|
|||||||
spread: 0.014 + 0.040 * strength * strength,
|
spread: 0.014 + 0.040 * strength * strength,
|
||||||
// Food units. Scaled so a dense arrangement supports roughly a
|
// Food units. Scaled so a dense arrangement supports roughly a
|
||||||
// thousand agents and a sparse one a few hundred; see the report.
|
// 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.
|
// Strong events seed life as well as food.
|
||||||
spawn: strength > 0.62 ? (o.voice === 0 ? 3 : o.voice === 1 ? 2 : 1) : 0,
|
spawn: strength > 0.62 ? (o.voice === 0 ? 3 : o.voice === 1 ? 2 : 1) : 0,
|
||||||
born: 0,
|
born: 0,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// The track's key sets the world's hue. Noise sets how far the three castes
|
// The track's key sets the world's hue. Noise sets how far the voices and
|
||||||
// sit from each other on the wheel. Nothing here is a palette chosen by hand:
|
// the bodies sit from each other on the wheel. Nothing here is a palette
|
||||||
// two songs in the same key will still separate on saturation and on the
|
// chosen by hand: two songs in the same key will still separate on saturation
|
||||||
// contrast between the builder and the spark.
|
// and on the contrast between their macro-organisms.
|
||||||
|
|
||||||
const TAU = Math.PI * 2;
|
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 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);
|
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 bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright);
|
||||||
const fog = hsl(hue + 0.5, 0.6, 0.075);
|
const fog = hsl(hue + 0.5, 0.6, 0.075);
|
||||||
|
|
||||||
@@ -50,8 +62,10 @@ export function buildPalette(summary, rng) {
|
|||||||
bg,
|
bg,
|
||||||
fog,
|
fog,
|
||||||
classes: [c0, c1, c2],
|
classes: [c0, c1, c2],
|
||||||
|
bodies,
|
||||||
// Trails sit below the agents: the world is dimmer than what moves in it.
|
// Trails sit below the agents: the world is dimmer than what moves in it.
|
||||||
trails: [c0.map((v) => v * 0.55), c1.map((v) => v * 0.55), c2.map((v) => v * 0.55)],
|
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),
|
grain: hsl(hue + 0.5, 0.2, 0.5),
|
||||||
ink: [0.92, 0.94, 0.96],
|
ink: [0.92, 0.94, 0.96],
|
||||||
};
|
};
|
||||||
|
|||||||
+104
-72
@@ -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
|
// A declarative, per-song description of *which* behaviour exists, compiled
|
||||||
// same behaviours, stronger or weaker. A rule program is the upgrade: a
|
// once deterministically from the analysis + seed:
|
||||||
// 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
|
// strains the population's personalities (LittleVM programs live in
|
||||||
// growth rules (mutualism bonus, social floor, appetite).
|
// strains.js); each is an economy entry: speed, deposit, cap,
|
||||||
// rules 3×3 signed interaction matrix: +attract / −repel, from the
|
// growth, appetite — and the growth rules (synergy: a strain
|
||||||
// band-correlation graph.
|
// breeds fastest in a mixed, same-body crowd).
|
||||||
// sections per-section OPERATOR REGIMES: a breakdown switches swarming off
|
// wells the living well policy: how many may live at once.
|
||||||
// and inscription on; a drop opens the floodgates; an intro keeps
|
// bodies how many macro-organisms the song opens with, and the cap
|
||||||
// everyone gentle. Structural, not magnitude.
|
// (allegiance is per-organism and changes over time).
|
||||||
// events which voice feeds which caste, and how hard.
|
// 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.
|
// memory how long the world keeps what it writes.
|
||||||
//
|
//
|
||||||
// Everything is a pure function of (analysis summary, derived genome, seed
|
// Everything is a pure function of (analysis summary, derived genome, strains,
|
||||||
// stream), so the program is part of the song and the prepared-artifact path
|
// seed stream), so the program is part of the song and the prepared-artifact
|
||||||
// rebuilds bit-identical programs.
|
// path rebuilds it bit-identically.
|
||||||
|
|
||||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
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 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.
|
* Per-section operator regimes — the structural core. Each operator is a
|
||||||
* Each operator is a weight on a behaviour; roughly: <0.25 the behaviour is
|
* weight on a behaviour; roughly: <0.25 the behaviour is effectively OFF,
|
||||||
* effectively OFF, ~1.0 it runs normally, >1.5 it dominates.
|
* ~1.0 it runs normally, >1.5 it dominates.
|
||||||
* chemo follow the food gradient
|
* chemo follow the food gradient
|
||||||
* attract follow/avoid the other castes (signed rules, scaled here)
|
|
||||||
* disperse spread away from one's own crowd
|
* 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
|
* turbulence noise-driven jitter
|
||||||
|
* peace war ↔ truce between bodies (1 = neutral; <1 hostile, >1 calm)
|
||||||
* speed / growth / deposit / split / spawn
|
* speed / growth / deposit / split / spawn
|
||||||
*/
|
*/
|
||||||
export const REGIMES = {
|
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 },
|
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, 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 },
|
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, 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 },
|
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, 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 },
|
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, 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 },
|
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, 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 },
|
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.
|
// 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
|
* 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
|
* 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) {
|
function songScales(summary) {
|
||||||
const s = summary;
|
const s = summary;
|
||||||
@@ -71,7 +62,6 @@ function songScales(summary) {
|
|||||||
const tone = clamp01(1 - s.flatness);
|
const tone = clamp01(1 - s.flatness);
|
||||||
return {
|
return {
|
||||||
chemo: 0.80 + 0.35 * clamp01(s.centroid), // bright mixes smell harder
|
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
|
disperse: 0.70 + 0.60 * noise, // noisy tracks scatter
|
||||||
home: 0.75 + 0.55 * tone, // tonal tracks hold place
|
home: 0.75 + 0.55 * tone, // tonal tracks hold place
|
||||||
turbulence: 0.55 + 1.0 * noise, // noisy tracks jitter
|
turbulence: 0.55 + 1.0 * noise, // noisy tracks jitter
|
||||||
@@ -80,33 +70,60 @@ function songScales(summary) {
|
|||||||
deposit: 0.70 + 0.70 * tone, // tonal tracks inscribe more
|
deposit: 0.70 + 0.70 * tone, // tonal tracks inscribe more
|
||||||
split: 0.85 + 0.35 * density,
|
split: 0.85 + 0.35 * density,
|
||||||
spawn: 0.80 + 0.50 * beat,
|
spawn: 0.80 + 0.50 * beat,
|
||||||
|
peace: 0.85 + 0.30 * tone, // tonal tracks settle easier
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Compile the world's rule program for a song.
|
||||||
* @param {object} summary analysis.summary
|
* @param {object} summary analysis.summary
|
||||||
* @param {object} genome deriveGenome() output (names, classes, interaction,
|
* @param {object} genome deriveGenome() output
|
||||||
* laws, jitter, memory half-lives)
|
* @param {Array} strains compileStrains() output (the personalities)
|
||||||
* @param {object} rng seeded Rng fork (unused today — kept in signature so
|
|
||||||
* a future rule search can jitter the program)
|
|
||||||
*/
|
*/
|
||||||
export function compileProgram(summary, genome) {
|
export function compileProgram(summary, genome, strains) {
|
||||||
const scales = songScales(summary);
|
const scales = songScales(summary);
|
||||||
const castes = genome.classes.map((c, i) => ({
|
const cons = consonance(summary);
|
||||||
name: CASTE_NAMES[i],
|
|
||||||
speed: c.speed,
|
// The song opens a few bodies (2–4): a rich arrangement founds more.
|
||||||
seek: c.seek,
|
const balance = summary.bandBalance || [0.33, 0.33, 0.33];
|
||||||
dispersion: BASE_DISPERSION[i],
|
const spread = Math.max(...balance) - Math.min(...balance);
|
||||||
cap: capFor(c.split),
|
const opening = clamp(2 + (spread > 0.22 ? 1 : 0) + (genome.laws.length >= 4 ? 1 : 0), 2, 4);
|
||||||
growth: growthFor(c.split),
|
|
||||||
eat: EAT_RATE,
|
// Each voice speaks to a strain: low → the heaviest writer, mid → the
|
||||||
deposit: c.deposit,
|
// commuter, high → the restless one; whoever exists takes the stage.
|
||||||
// Growth rules per caste:
|
const pick = (role, fallback) => {
|
||||||
// mutualism — all castes breed faster among the others.
|
const idx = strains.findIndex((s) => s.role === role);
|
||||||
mutual: { base: 0.30, threshold: 6 },
|
return idx >= 0 ? idx : fallback % strains.length;
|
||||||
social: null,
|
};
|
||||||
}));
|
const voiceStrain = [
|
||||||
|
pick('builder', 0),
|
||||||
|
pick('grazer', 1),
|
||||||
|
pick('scout', 2),
|
||||||
|
];
|
||||||
|
|
||||||
const sections = genome.laws.map((law) => {
|
const sections = genome.laws.map((law) => {
|
||||||
const base = REGIMES[law.kind] || REGIMES.sustain;
|
const base = REGIMES[law.kind] || REGIMES.sustain;
|
||||||
@@ -122,19 +139,34 @@ export function compileProgram(summary, genome) {
|
|||||||
memory: {
|
memory: {
|
||||||
trailHalfLife: genome.trailHalfLife,
|
trailHalfLife: genome.trailHalfLife,
|
||||||
fieldHalfLife: genome.fieldHalfLife,
|
fieldHalfLife: genome.fieldHalfLife,
|
||||||
// How fast population haze bleeds; the track's noise sets it.
|
// How fast population haze bleeds; the track's noise sets it, but
|
||||||
diffusion: 0.004 + 0.045 * clamp01(summary.flatness),
|
// never to zero — a still world is a dead one.
|
||||||
jitter: 0.02 + 0.5 * clamp01(summary.flatness),
|
diffusion: 0.006 + 0.045 * clamp01(summary.flatness),
|
||||||
|
jitter: 0.06 + 0.5 * clamp01(summary.flatness),
|
||||||
},
|
},
|
||||||
castes,
|
strains: strains.map((s) => ({
|
||||||
rules: genome.interaction,
|
name: s.name,
|
||||||
home: { shared: true, pull: [1.5, 1.1, 0.7], reach: 0.55 },
|
role: s.role,
|
||||||
society: { ceiling: 105, global: 60000 },
|
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,
|
sections,
|
||||||
// Which voice feeds which caste: each event splats food and restocks
|
events: {
|
||||||
// the society. The voice speaks first and loudest; sparks — fast and
|
splash: strains.map((s) => SPLASH_BY_ROLE[s.role] ?? 0.5),
|
||||||
// ephemeral — take a larger splash of every hit.
|
voiceStrain,
|
||||||
events: { splash: [0.5, 0.5, 0.9] },
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// The song, as a prescription for a world. One call, no rendering, no clock.
|
// The song, as a prescription for a world. One call, no rendering, no clock.
|
||||||
//
|
//
|
||||||
// events where food appears, and when
|
// 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
|
// 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
|
// laws how the rules change at section boundaries
|
||||||
// palette the key, as colour
|
|
||||||
//
|
//
|
||||||
// Everything downstream is a pure function of this object plus a frame number.
|
// 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 { buildEvents, indexEvents } from './events.js';
|
||||||
import { deriveGenome } from './species.js';
|
import { deriveGenome } from './species.js';
|
||||||
import { compileProgram } from './rules.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) {
|
export function buildSong(analysis, seed = 1) {
|
||||||
const rng = new Rng(seed >>> 0).fork('metabolism');
|
const rng = new Rng(seed >>> 0).fork('metabolism');
|
||||||
const palette = buildPalette(analysis.summary, rng.fork('palette'));
|
const palette = buildPalette(analysis.summary, rng.fork('palette'));
|
||||||
const events = buildEvents(analysis, rng.fork('events'));
|
const events = buildEvents(analysis, rng.fork('events'));
|
||||||
const genome = deriveGenome(analysis, rng.fork('genome'));
|
const genome = deriveGenome(analysis, rng.fork('genome'));
|
||||||
const program = compileProgram(analysis.summary, genome);
|
const strains = compileStrains(analysis.summary, genome, rng.fork('strains'));
|
||||||
const tribes = compileTribes(analysis.summary, genome, rng.fork('tribes'));
|
const program = compileProgram(analysis.summary, genome, strains);
|
||||||
|
const wells = compileWells(analysis, rng.fork('wells'));
|
||||||
return {
|
return {
|
||||||
seed: seed >>> 0,
|
seed: seed >>> 0,
|
||||||
analysis,
|
analysis,
|
||||||
@@ -30,10 +34,11 @@ export function buildSong(analysis, seed = 1) {
|
|||||||
index: indexEvents(events, analysis.frames),
|
index: indexEvents(events, analysis.frames),
|
||||||
genome,
|
genome,
|
||||||
program,
|
program,
|
||||||
tribes,
|
strains,
|
||||||
|
wells,
|
||||||
name: genome.name,
|
name: genome.name,
|
||||||
laws: genome.laws,
|
laws: genome.laws,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { buildPalette, buildEvents, deriveGenome, compileProgram, compileTribes };
|
export { buildPalette, buildEvents, deriveGenome, compileProgram, compileStrains };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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)})`);
|
||||||
|
});
|
||||||
@@ -33,12 +33,12 @@ test('different songs get structurally different programs', () => {
|
|||||||
const runner = load('runner').program;
|
const runner = load('runner').program;
|
||||||
const dusk = load('dusk').program;
|
const dusk = load('dusk').program;
|
||||||
|
|
||||||
// Castes differ (speed/seek/cap/growth from the track's stats).
|
// Strains differ (speed/deposit/cap/growth from the track's stats).
|
||||||
const dr = JSON.stringify(drone.castes);
|
const dr = JSON.stringify(drone.strains);
|
||||||
assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.castes)));
|
assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.strains)));
|
||||||
|
|
||||||
// Interaction rules differ (band-correlation graph).
|
// Diplomacy differs (the tracks' own consonance).
|
||||||
assert.notEqual(JSON.stringify(drone.rules), JSON.stringify(dusk.rules));
|
assert.notEqual(JSON.stringify(drone.diplomacy), JSON.stringify(dusk.diplomacy));
|
||||||
|
|
||||||
// At least one operator family differs materially in the same section kind.
|
// At least one operator family differs materially in the same section kind.
|
||||||
const dropOf = (p) => p.sections.find((s) => s.kind === 'drop')?.operators;
|
const dropOf = (p) => p.sections.find((s) => s.kind === 'drop')?.operators;
|
||||||
|
|||||||
@@ -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)})`);
|
||||||
|
});
|
||||||
@@ -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)})`);
|
|
||||||
});
|
|
||||||
@@ -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,
|
assert.ok(withGenesis.world.count > without.world.count * 1.1,
|
||||||
`genesis world should be more populated (${withGenesis.world.count} vs ${without.world.count})`);
|
`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.
|
// …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()})`);
|
`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.history.alive[0], 0, 'genesis must not record into the film');
|
||||||
assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0');
|
assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0');
|
||||||
|
|||||||
Reference in New Issue
Block a user