1827 lines
78 KiB
JavaScript
1827 lines
78 KiB
JavaScript
// The macro ecology (EVOLUTION.md M1, retargeted by M2.7).
|
||
//
|
||
// One population, many minds, several bodies. The world is a single
|
||
// population field whose density is partitioned two ways:
|
||
//
|
||
// strains personality — the song's compiled LittleVM programs. Each
|
||
// strain advects by its own program's decisions; the mix of
|
||
// strains evolves by local selection (what eats and grows
|
||
// where it stands, propagates there).
|
||
// allegiance membership — which macro-organism ("body") the organisms in
|
||
// a cell belong to. Allegiance is a composition field advected
|
||
// frozen-in with the population flux, and it CHANGES: fronts
|
||
// convert, collapsing bodies leak their members to neighbours,
|
||
// cut-off splinters found new bodies, intermixed friends merge.
|
||
//
|
||
// Wells are alive too: a precompiled timeline plays births, drift and deaths
|
||
// of small, weak, numerous fertile places — the natural fronts between bodies.
|
||
//
|
||
// The front-end is unchanged: events land food, the section's law switches
|
||
// the rules at its boundary, palette comes from the key. The invariants:
|
||
//
|
||
// PERSISTENCE trail fields accumulate the geography for tens of seconds.
|
||
// CAUSALITY a hit still lands food, a ring and a local bloom exactly
|
||
// where the track put it; a well's birth and death are the
|
||
// track's own harmony.
|
||
// CONSEQUENCE every substep is a pure update of the previous one.
|
||
// DETERMINISM no Math.random, no wall clock, no per-substep RNG: the static
|
||
// jitter noise field is generated once from the seeded Rng and
|
||
// the substep order is canonical.
|
||
|
||
import { Rng } from '../rng.js';
|
||
import { wellPos, wellEnv, buildKernel } from './wells.js';
|
||
import {
|
||
runProgram, ensureFlat, SENSE_COUNT, GRAD_COUNT, ACTION_COUNT, REGS,
|
||
S_FOOD, S_SELF, S_KIN, S_FOE, S_TRAIL, S_LAW, S_HUNGER, S_EDGE, S_TIME, S_TOTAL, S_WELL, S_PEACE, S_ROCK, S_GARDEN,
|
||
G_GARDEN,
|
||
G_FOOD, G_SELF, G_KIN, G_FOE, G_WELL, G_ROCK,
|
||
} from '../song/strains.js';
|
||
|
||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||
|
||
export const FIELD = 128;
|
||
export const RENDER_FIELD = 256; // speck/texture resolution at render (2× up)
|
||
export const SUBSTEPS = 5; // sim steps per rendered frame (the old world: 1)
|
||
export const MAX_BODIES = 8;
|
||
|
||
// Ink per individual per second, world units (tuned against a ~15 s half-life
|
||
// so a full cell writes a visible line in seconds).
|
||
const DEPOSIT_SCALE = 0.012;
|
||
|
||
// The decision cadence: the strains think every VM_EVERY-th substep (their
|
||
// decisions persist in the action buffer between thoughts). 2 = ~15 thoughts
|
||
// per second of film at 5 substeps.
|
||
const VM_EVERY = 2;
|
||
|
||
// Politics cadence (frames at the sim's fps). Allegiance never changes sides:
|
||
// bodies are founded once and persist; war kills, it does not convert.
|
||
const COLONIZE_EVERY = 60; // ~2 s: a blessed frontier settlement may found
|
||
|
||
/** A normalised Gaussian footprint (cell indices + weights) per coarse block. */
|
||
function buildBlockKernels(F, FC) {
|
||
const kernels = [];
|
||
const bs = F / FC;
|
||
for (let b = 0; b < FC * FC; b++) {
|
||
const cx = ((b % FC) + 0.5) * bs - 0.5;
|
||
const cy = (((b / FC) | 0) + 0.5) * bs - 0.5;
|
||
const sig = Math.max(1.4, bs * 0.9);
|
||
const r = Math.ceil(sig * 2.2);
|
||
const denom = 1 / (2 * sig * sig);
|
||
const idx = [];
|
||
const w = [];
|
||
let total = 0;
|
||
for (let dy = -r; dy <= r; dy++) {
|
||
const py = Math.round(cy) + dy;
|
||
if (py < 0 || py >= F) continue;
|
||
for (let dx = -r; dx <= r; dx++) {
|
||
const px = Math.round(cx) + dx;
|
||
if (px < 0 || px >= F) continue;
|
||
const g = Math.exp(-(dx * dx + dy * dy) * denom);
|
||
if (g < 0.02) continue;
|
||
idx.push(py * F + px);
|
||
w.push(g);
|
||
total += g;
|
||
}
|
||
}
|
||
const k = total > 0 ? 1 / total : 0;
|
||
kernels.push({ idx: Int32Array.from(idx), w: Float32Array.from(w, (v) => v * k) });
|
||
}
|
||
return kernels;
|
||
}
|
||
|
||
export class World {
|
||
constructor(song, { fps = 30, field = FIELD, seed = song.seed, substeps = SUBSTEPS, genesis, vmEvery = VM_EVERY } = {}) {
|
||
this.song = song;
|
||
this.fps = fps;
|
||
this.dt = 1 / fps;
|
||
this.field = field;
|
||
this.F = field;
|
||
this.substeps = substeps;
|
||
this.genome = song.genome;
|
||
this.vmEvery = Math.max(1, Math.round(vmEvery));
|
||
this.program = song.program;
|
||
this.strains = song.strains;
|
||
this.m = this.strains.length;
|
||
this.palette = song.palette;
|
||
this.wellTimeline = song.wells.wells;
|
||
this.rng = new Rng(seed >>> 0).fork('world');
|
||
|
||
// Pre-simulation (genesis): how long the world evolves before frame 0,
|
||
// so the opening is an old ecosystem, not a blank plate. The amount is
|
||
// the song's own: a long intro matures the opening world longer, a
|
||
// dense or beatless track settles more slowly.
|
||
const g = genesis !== undefined
|
||
? genesis
|
||
: 1.5
|
||
+ 2.5 * clamp01(Math.min(1, (song.laws[1]?.start ?? 4) / 16))
|
||
+ 1.5 * clamp01(song.summary.onsetDensity / 6)
|
||
+ 2 * (1 - clamp01(song.summary.beatStrength));
|
||
this.genesisFrames = Math.max(0, Math.round(clamp01((g - 2) / 7) * 9 * fps));
|
||
|
||
const n = field * field;
|
||
this.n = n;
|
||
this.resource = new Float32Array(n);
|
||
this.density = new Float32Array(n); // total population per cell
|
||
|
||
// The population, partitioned by personality (strain).
|
||
this.pop = Array.from({ length: this.m }, () => new Float32Array(n));
|
||
this.popNext = Array.from({ length: this.m }, () => new Float32Array(n));
|
||
|
||
// Allegiance: the population partitioned by body. alleg[b][i] is the
|
||
// DENSITY of body-b organisms at cell i, so Σ_b alleg[b][i] equals
|
||
// the cell's total population — a concentration, advected frozen-in
|
||
// with the flux and renormalised to the population every frame.
|
||
this.B = this.program.bodies.max;
|
||
this.alleg = Array.from({ length: this.B }, () => new Float32Array(n));
|
||
this.allegNext = Array.from({ length: this.B }, () => new Float32Array(n));
|
||
|
||
// Trail history, one tinted layer per body — the geography the
|
||
// audience sees, coloured by who wrote it.
|
||
this.trail = Array.from({ length: this.B }, () => new Float32Array(n));
|
||
|
||
// The macro-organisms themselves.
|
||
this.bodies = Array.from({ length: this.B }, (_, b) => ({
|
||
id: b, alive: false, color: b, founded: -1, parent: -1,
|
||
cx: 0.5, cy: 0.5, presence: 0, peak: 0,
|
||
}));
|
||
this.aliveBodies = [];
|
||
// Diplomacy: pairwise affinity between bodies (consonance of their
|
||
// founding harmony bends it; the section's peace bends it live).
|
||
this.bodyAff = Array.from({ length: this.B }, () => new Float32Array(this.B));
|
||
this.warmth = 0;
|
||
|
||
this.activity = new Float32Array(32 * 32);
|
||
|
||
// Rock (M2.8): the inert resource. A barrier the organisms cannot
|
||
// cross, that the song drops and shatters, and that builders quarry,
|
||
// carry and wall with. rockMask marks the impassable cells.
|
||
this.rock = new Float32Array(n);
|
||
this.rockMask = new Uint8Array(n);
|
||
this.rockFallen = 0; // total stone the song has dropped
|
||
// Flora (M2.9): the wild plants — the world's pioneers, living in
|
||
// ISLANDS scattered at birth. They ARE food, they regrow on their
|
||
// own, and they BLOOM when the world is starving or dwindling
|
||
// (seeding it back to life), then recede once the population is
|
||
// strong and feeding itself again. Never enough to sustain a
|
||
// large world, and never anywhere but on their own seed ground.
|
||
this.flora = new Float32Array(n);
|
||
this.floraSeed = new Uint8Array(n); // the islands: where flora may live
|
||
this.famine = 0; // smoothed mean hunger of the population
|
||
this.peakPop = 0; // recent population high-water mark
|
||
this.hungerMass = 0; // per-frame accumulator (the reaction fills it)
|
||
// Cultivated ground: worked stone that slowly yields food, then
|
||
// petrifies back to rock. garden[i] is the fertility left.
|
||
this.garden = new Float32Array(n);
|
||
this.gardenSrc = new Float32Array(n);
|
||
// Recent tending, per cell: a visited garden yields far more.
|
||
this.tend = new Float32Array(n);
|
||
|
||
// Deterministic jitter: static signed noise fields from the seeded RNG,
|
||
// read every substep. The track's flatness sets how loud it is.
|
||
this.noise = new Float32Array(n);
|
||
this.noise2 = new Float32Array(n);
|
||
for (let i = 0; i < n; i++) {
|
||
this.noise[i] = this.rng.signed();
|
||
this.noise2[i] = this.rng.signed();
|
||
}
|
||
|
||
// Fine-grid 4-neighbour indices, precomputed once (no bounds checks).
|
||
this.nUp = new Int32Array(n);
|
||
this.nDown = new Int32Array(n);
|
||
this.nLeft = new Int32Array(n);
|
||
this.nRight = new Int32Array(n);
|
||
for (let y = 0; y < field; y++) {
|
||
for (let x = 0; x < field; x++) {
|
||
const i = y * field + x;
|
||
this.nUp[i] = y === 0 ? i : i - field;
|
||
this.nDown[i] = y === field - 1 ? i : i + field;
|
||
this.nLeft[i] = x === 0 ? i : i - 1;
|
||
this.nRight[i] = x === field - 1 ? i : i + 1;
|
||
}
|
||
}
|
||
|
||
// Coarse force grid: block sums of food + strains + bodies, forces per
|
||
// block, plus a fine→coarse index table. Field 128 → 64² (2×2 blocks).
|
||
const fcSide = Math.max(16, Math.round(field / 2));
|
||
const fc = fcSide * fcSide;
|
||
this.FC = fcSide;
|
||
this.fc = fc;
|
||
this.blkR = new Float32Array(fc);
|
||
this.blkPop = Array.from({ length: this.m }, () => new Float32Array(fc));
|
||
this.blkTrail = Array.from({ length: this.B }, () => new Float32Array(fc));
|
||
this.blkBody = Array.from({ length: this.B }, () => new Float32Array(fc));
|
||
this.blkTotal = new Float32Array(fc);
|
||
this.blkRock = new Float32Array(fc);
|
||
this.blkGarden = new Float32Array(fc);
|
||
this.domB = new Int8Array(fc).fill(-1);
|
||
this.subB = new Int8Array(fc).fill(-1);
|
||
this.fxX = Array.from({ length: this.m }, () => new Float32Array(fc));
|
||
this.fxY = Array.from({ length: this.m }, () => new Float32Array(fc));
|
||
this.velX = new Float32Array(fc);
|
||
this.velY = new Float32Array(fc);
|
||
this.wellField = new Float32Array(fc);
|
||
this.rockField = new Float32Array(fc); // smoothed stone: the far smell
|
||
this.ancX = Array.from({ length: this.m }, () => new Float32Array(fc));
|
||
this.ancY = Array.from({ length: this.m }, () => new Float32Array(fc));
|
||
this.synergy = new Float32Array(this.m * fc);
|
||
this.valScratch = new Float64Array(this.m);
|
||
this.growScratch = new Float64Array(this.m);
|
||
this.hostility = new Float32Array(fc);
|
||
this.hitAt = new Float32Array(fc);
|
||
// The last aim per (strain, block): the senses of WORK look where
|
||
// the work points, one substep back — sight follows intent.
|
||
this.aimX = new Int8Array(this.m * fc);
|
||
this.aimY = new Int8Array(this.m * fc);
|
||
this.cUp = new Int32Array(fc);
|
||
this.cDown = new Int32Array(fc);
|
||
this.cLeft = new Int32Array(fc);
|
||
this.cRight = new Int32Array(fc);
|
||
for (let y = 0; y < fcSide; y++) {
|
||
for (let x = 0; x < fcSide; x++) {
|
||
const i = y * fcSide + x;
|
||
this.cUp[i] = y === 0 ? i : i - fcSide;
|
||
this.cDown[i] = y === fcSide - 1 ? i : i + fcSide;
|
||
this.cLeft[i] = x === 0 ? i : i - 1;
|
||
this.cRight[i] = x === fcSide - 1 ? i : i + 1;
|
||
}
|
||
}
|
||
this.coarseOf = new Int32Array(n);
|
||
const bs = field / fcSide; // fine cells per coarse side (2)
|
||
for (let y = 0; y < field; y++) {
|
||
const cy = Math.min(fcSide - 1, (y / bs) | 0);
|
||
for (let x = 0; x < field; x++) {
|
||
const cx = Math.min(fcSide - 1, (x / bs) | 0);
|
||
this.coarseOf[y * field + x] = cy * fcSide + cx;
|
||
}
|
||
}
|
||
|
||
// Quarried burden per strain, a fine field advected with the
|
||
// population flux — stone is genuinely HAULED: dug at the quarry,
|
||
// carried with the crowd, walled at home.
|
||
this.carry = Array.from({ length: this.m }, () => new Float32Array(n));
|
||
this.carryNext = Array.from({ length: this.m }, () => new Float32Array(n));
|
||
// Rounded build footprints: a Gaussian kernel per coarse block, so
|
||
// walls rise organically instead of as square tiles.
|
||
this.buildKernel = buildBlockKernels(field, Math.max(16, Math.round(field / 2)));
|
||
|
||
// Strain programs run on this decision grid: registers are the
|
||
// strains' persistent memory, the action slots carry each decision.
|
||
this.stMem = new Float32Array(this.m * REGS * fc);
|
||
this.stAct = new Float32Array(this.m * ACTION_COUNT * fc);
|
||
this.ss = new Float32Array(SENSE_COUNT);
|
||
this.gg = new Float32Array(GRAD_COUNT);
|
||
this.scratch = new Float32Array(ACTION_COUNT);
|
||
|
||
// Economy per strain.
|
||
this.caps = this.program.strains.map((s) => s.cap);
|
||
this.growth = this.program.strains.map((s) => s.growth);
|
||
this.eatR = this.program.strains.map((s) => s.eat);
|
||
this.depositR = this.program.strains.map((s) => s.deposit);
|
||
this.syn = this.program.strains.map((s) => s.synergy);
|
||
this.cap = 0;
|
||
for (let s = 0; s < this.m; s++) this.cap += this.caps[s] * n;
|
||
this.avgSpeed = this.caps.length
|
||
? this.program.strains.reduce((a, s) => a + s.speed, 0) / this.m
|
||
: 0.05;
|
||
|
||
// Living wells: runtime views over the compiled timeline.
|
||
this.wells = this.wellTimeline.map((w) => ({ ...w, kernel: null, kx: -9, ky: -9, peak: 1 }));
|
||
|
||
// Home basins: every strain anchors to its NEAREST LIVING WELL — when
|
||
// wells drift, the colonies drift with them; when one dies, the pull
|
||
// moves to the next fertile place.
|
||
this.homePull = this.m > 0 ? new Float32Array(this.m).fill(1.0) : new Float32Array(0);
|
||
this.homeReach = 0.55;
|
||
|
||
// History aligned with the analysis frames (metrics regress on it).
|
||
this.history = {
|
||
alive: new Float32Array(song.analysis.frames),
|
||
movement: new Float32Array(song.analysis.frames),
|
||
food: new Float32Array(song.analysis.frames),
|
||
births: new Float32Array(song.analysis.frames),
|
||
deaths: new Float32Array(song.analysis.frames),
|
||
bodies: new Float32Array(song.analysis.frames),
|
||
};
|
||
|
||
this.frame = -1;
|
||
this.substepCount = 0;
|
||
this.pulses = [];
|
||
this.totalPop = 0;
|
||
this.moveAccum = 0;
|
||
this.birthsThisFrame = 0;
|
||
this.deathsThisFrame = 0;
|
||
|
||
this.seedRocks();
|
||
this.seedFlora();
|
||
this.refreshRockMask();
|
||
this.foundOpeningBodies();
|
||
this.seedInitial();
|
||
this.runGenesis();
|
||
}
|
||
|
||
lawForFrame(frame) {
|
||
const laws = this.song.laws;
|
||
const t = frame / this.fps;
|
||
for (let i = laws.length - 1; i >= 0; i--) {
|
||
if (t >= laws[i].start) return laws[i];
|
||
}
|
||
return laws[0];
|
||
}
|
||
|
||
/** The rule program's current section: operator regime + energy. */
|
||
regimeForFrame(frame) {
|
||
const sections = this.program.sections;
|
||
const t = frame / this.fps;
|
||
for (let i = sections.length - 1; i >= 0; i--) {
|
||
if (t >= sections[i].start) return sections[i];
|
||
}
|
||
return sections[0];
|
||
}
|
||
|
||
/** How warm two bodies are to each other right now (−1 war … +1 truce). */
|
||
diplomacy(a, b) {
|
||
if (a === b) return 1;
|
||
return Math.max(-1, Math.min(1, this.bodyAff[a][b] + this.warmth));
|
||
}
|
||
|
||
// ---- founding & seeding ------------------------------------------------
|
||
|
||
/**
|
||
* Found the opening bodies: the song's first strong events are where its
|
||
* macro-organisms take hold. Each founds a body; the founding pitch
|
||
* classes set the pairwise affinity (harmonious founders stay kin).
|
||
*/
|
||
foundOpeningBodies() {
|
||
const opening = this.program.bodies.opening;
|
||
const strong = this.song.events
|
||
.filter((e) => e.strength > 0.3)
|
||
.slice(0, 24);
|
||
const picks = [];
|
||
for (const e of strong) {
|
||
if (picks.length >= opening) break;
|
||
if (picks.every((p) => Math.hypot(p.x - e.x, p.y - e.y) > 0.22)) picks.push(e);
|
||
}
|
||
while (picks.length < opening && strong.length) {
|
||
picks.push(strong[picks.length % strong.length]);
|
||
}
|
||
const chroma = this.song.summary.chroma;
|
||
picks.forEach((e, b) => {
|
||
const body = this.bodies[b];
|
||
body.alive = true;
|
||
body.founded = 0;
|
||
body.cx = e.x;
|
||
body.cy = e.y;
|
||
this.aliveBodies.push(b);
|
||
void chroma;
|
||
});
|
||
// Affinity: founders born on nearby pitch classes are natural kin.
|
||
for (let a = 0; a < this.aliveBodies.length; a++) {
|
||
for (let c = a + 1; c < this.aliveBodies.length; c++) {
|
||
const aff = 0.30 * (1 - Math.min(1, Math.hypot(
|
||
this.bodies[a].cx - this.bodies[c].cx,
|
||
this.bodies[a].cy - this.bodies[c].cy) / 0.5));
|
||
this.bodyAff[a][c] = aff;
|
||
this.bodyAff[c][a] = aff;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Furnish the dish and wake it into the song's own opening state.
|
||
*
|
||
* The wells furnish FOOD (the fertile places) but not life: the opening
|
||
* colonies are spawned where the track's first bars actually land — the
|
||
* song drives where organisms appear, and which body takes them.
|
||
*/
|
||
seedInitial() {
|
||
const F = this.field;
|
||
const res = this.resource;
|
||
for (const well of this.wells) {
|
||
if (well.born > 0) continue;
|
||
this.refreshKernel(well, 0);
|
||
const idx = well.kernel.idx;
|
||
const w = well.kernel.w;
|
||
for (let i = 0; i < idx.length; i++) {
|
||
res[idx[i]] = Math.min(1.6, 1.2 * w[i] / well.peak);
|
||
}
|
||
}
|
||
// The song's own opening, replayed as the genesis: each event places
|
||
// food AND spawns life there — into the strain its voice speaks to,
|
||
// under the flag of the nearest founding body.
|
||
const reach = this.genesisFrames || this.fps * 3;
|
||
const pool = [];
|
||
for (const e of this.song.events) {
|
||
if (pool.length >= 16 || e.frame >= reach) break;
|
||
pool.push(e);
|
||
}
|
||
for (const e of this.song.events) {
|
||
if (pool.length >= 6) break;
|
||
if (!pool.includes(e)) pool.push(e);
|
||
}
|
||
const vs = this.program.events.voiceStrain;
|
||
const splash = this.program.events.splash;
|
||
for (const e of pool) {
|
||
this.splat(e.x, e.y, e.spread, e.amount);
|
||
const cell = Math.min(F - 1, (e.y * F) | 0) * F + Math.min(F - 1, (e.x * F) | 0);
|
||
const body = this.nearestBody(e.x, e.y);
|
||
const burst = 2.0 + 5.0 * e.strength;
|
||
let spawned = burst;
|
||
this.pop[vs[e.voice]][cell] += burst;
|
||
for (let s = 0; s < this.m; s++) {
|
||
if (s !== vs[e.voice]) { this.pop[s][cell] += burst * 0.5 * splash[s]; spawned += burst * 0.5 * splash[s]; }
|
||
}
|
||
this.addAllegiance(cell, body, spawned);
|
||
}
|
||
this.recount();
|
||
this.record(-1);
|
||
}
|
||
|
||
/** Body of the nearest living body-core (canonical tie-break: lowest id). */
|
||
nearestBody(x, y) {
|
||
let best = -1;
|
||
let bd = Infinity;
|
||
for (const b of this.aliveBodies) {
|
||
const body = this.bodies[b];
|
||
const d = Math.hypot(body.cx - x, body.cy - y);
|
||
if (d < bd - 1e-9) { bd = d; best = b; }
|
||
}
|
||
return best >= 0 ? best : (this.aliveBodies[0] ?? 0);
|
||
}
|
||
|
||
/** Add body-density at a cell (the composition then tracks population). */
|
||
addAllegiance(i, body, amount) {
|
||
this.alleg[body][i] += amount;
|
||
}
|
||
|
||
/**
|
||
* Pre-simulate the genesis epoch (frames −G … −1). No events fire (the
|
||
* index has nothing below 0), so the world settles under the intro regime:
|
||
* several generations of growth, diffusion, deposition and decay decide
|
||
* where the opening's bodies actually stand. History stays untouched.
|
||
*/
|
||
runGenesis() {
|
||
const G = this.genesisFrames;
|
||
for (let f = 0; f < G; f++) {
|
||
this.frame++;
|
||
const regime = this.regimeForFrame(this.frame);
|
||
this.pulses.length = 0;
|
||
this.birthsThisFrame = 0;
|
||
this.deathsThisFrame = 0;
|
||
this.moveAccum = 0;
|
||
this.warmth = Math.max(-1, Math.min(1,
|
||
this.program.diplomacy.base * 0.6 + 0.35 * (regime.operators.peace - 1)));
|
||
const living = this.framePrep(regime);
|
||
this.updateRockField();
|
||
this.inject(this.frame, regime);
|
||
this.refreshRockMask();
|
||
for (let s = 0; s < this.substeps; s++) this.substep(regime);
|
||
this.decay(regime, living);
|
||
this.politics(regime, true);
|
||
}
|
||
this.frame = -1;
|
||
this.pulses.length = 0;
|
||
}
|
||
|
||
recount() {
|
||
const n = this.n;
|
||
this.density.fill(0);
|
||
let s = 0;
|
||
for (let c = 0; c < this.m; c++) {
|
||
const p = this.pop[c];
|
||
const d = this.density;
|
||
for (let i = 0; i < n; i++) {
|
||
const v = p[i];
|
||
d[i] += v;
|
||
s += v;
|
||
}
|
||
}
|
||
this.totalPop = s;
|
||
}
|
||
|
||
get count() {
|
||
return this.totalPop;
|
||
}
|
||
|
||
// ---- field access ----------------------------------------------------
|
||
|
||
sample(fx, fy) {
|
||
const F = this.field;
|
||
const gx = Math.max(0, Math.min(F - 1.001, fx * (F - 1)));
|
||
const gy = Math.max(0, Math.min(F - 1.001, fy * (F - 1)));
|
||
const x0 = gx | 0;
|
||
const y0 = gy | 0;
|
||
const tx = gx - x0;
|
||
const ty = gy - y0;
|
||
const i = y0 * F + x0;
|
||
const a = this.resource[i];
|
||
const b = this.resource[i + 1];
|
||
const c = this.resource[i + F];
|
||
const d = this.resource[i + F + 1];
|
||
return (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty;
|
||
}
|
||
|
||
splat(x, y, spread, amount) {
|
||
const F = this.field;
|
||
const sigma = Math.max(0.8, spread * F);
|
||
const r = Math.ceil(sigma * 2.4);
|
||
const cx = Math.round(x * (F - 1));
|
||
const cy = Math.round(y * (F - 1));
|
||
const denom = 1 / (2 * sigma * sigma);
|
||
let total = 0;
|
||
const idx = [];
|
||
const w = [];
|
||
for (let dy = -r; dy <= r; dy++) {
|
||
const py = cy + dy;
|
||
if (py < 0 || py >= F) continue;
|
||
for (let dx = -r; dx <= r; dx++) {
|
||
const px = cx + dx;
|
||
if (px < 0 || px >= F) continue;
|
||
const k = Math.exp(-(dx * dx + dy * dy) * denom);
|
||
if (k < 0.01) continue;
|
||
idx.push(py * F + px);
|
||
w.push(k);
|
||
total += k;
|
||
}
|
||
}
|
||
if (total <= 0) return;
|
||
const k = amount / total;
|
||
const res = this.resource;
|
||
for (let i = 0; i < idx.length; i++) res[idx[i]] += w[i] * k;
|
||
}
|
||
|
||
// ---- rock ---------------------------------------------------------------
|
||
|
||
/** Scatter the world's starting stone: deterministic, from the seed. */
|
||
seedRocks() {
|
||
const n = this.program.rock.scatter;
|
||
for (let k = 0; k < n; k++) {
|
||
const x = this.rng.next();
|
||
const y = this.rng.next();
|
||
const sigma = 0.030 + 0.045 * this.rng.next();
|
||
const amount = 5.0 + 9.0 * this.rng.next();
|
||
this.rockSplat(x, y, sigma, amount);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add (or, with a negative amount, shave) rock in a Gaussian blob.
|
||
* Returns the touched cells so callers can crush or yield.
|
||
*/
|
||
rockSplat(x, y, spread, amount) {
|
||
const F = this.field;
|
||
const sigma = Math.max(0.8, spread * F);
|
||
const r = Math.ceil(sigma * 2.4);
|
||
const cx = Math.round(x * (F - 1));
|
||
const cy = Math.round(y * (F - 1));
|
||
const denom = 1 / (2 * sigma * sigma);
|
||
let total = 0;
|
||
const idx = [];
|
||
const w = [];
|
||
for (let dy = -r; dy <= r; dy++) {
|
||
const py = cy + dy;
|
||
if (py < 0 || py >= F) continue;
|
||
for (let dx = -r; dx <= r; dx++) {
|
||
const px = cx + dx;
|
||
if (px < 0 || px >= F) continue;
|
||
const k = Math.exp(-(dx * dx + dy * dy) * denom);
|
||
if (k < 0.01) continue;
|
||
idx.push(py * F + px);
|
||
w.push(k);
|
||
total += k;
|
||
}
|
||
}
|
||
if (total <= 0) return { idx: [], w: [], k: 0 };
|
||
const k = amount / total;
|
||
const rock = this.rock;
|
||
const pop = this.pop;
|
||
for (let i = 0; i < idx.length; i++) {
|
||
const cell = idx[i];
|
||
if (amount >= 0) {
|
||
const before = rock[cell];
|
||
rock[cell] = Math.min(2.2, rock[cell] + w[i] * k);
|
||
const gained = rock[cell] - before;
|
||
// A rockfall crushes what it lands on.
|
||
if (gained > 0.05) {
|
||
const crush = Math.max(0, 1 - gained * 0.55);
|
||
for (let s = 0; s < this.m; s++) pop[s][cell] *= crush;
|
||
}
|
||
} else {
|
||
rock[cell] = Math.max(0, rock[cell] + w[i] * k);
|
||
}
|
||
}
|
||
return { idx, w, k };
|
||
}
|
||
|
||
/** Scatter the wild flora: small patches, deterministic from the seed. */
|
||
seedFlora() {
|
||
const n = 9 + Math.round(6 * clamp01(this.song.summary.onsetDensity / 6));
|
||
for (let k = 0; k < n; k++) {
|
||
const x = this.rng.next();
|
||
const y = this.rng.next();
|
||
const sigma = 0.010 + 0.011 * this.rng.next();
|
||
const F = this.field;
|
||
const cx = Math.round(x * (F - 1));
|
||
const cy = Math.round(y * (F - 1));
|
||
const sig = Math.max(1.2, sigma * F);
|
||
const r = Math.ceil(sig * 2.2);
|
||
const denom = 1 / (2 * sig * sig);
|
||
for (let dy = -r; dy <= r; dy++) {
|
||
const py = cy + dy;
|
||
if (py < 0 || py >= F) continue;
|
||
for (let dx = -r; dx <= r; dx++) {
|
||
const px = cx + dx;
|
||
if (px < 0 || px >= F) continue;
|
||
const i = py * F + px;
|
||
if (this.rockMask[i]) continue;
|
||
const g = Math.exp(-(dx * dx + dy * dy) * denom);
|
||
this.floraSeed[i] = 1;
|
||
this.flora[i] = Math.min(0.75, this.flora[i] + g * (0.35 + 0.3 * this.rng.next()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The impassable frontier, recomputed after any rock change. */
|
||
refreshRockMask() {
|
||
const rock = this.rock;
|
||
const mask = this.rockMask;
|
||
for (let i = 0; i < rock.length; i++) mask[i] = rock[i] > 0.25 ? 1 : 0;
|
||
}
|
||
|
||
// ---- wells ---------------------------------------------------------------
|
||
|
||
/** (Re)build a living well's recharge kernel at its current position. */
|
||
refreshKernel(well, frame) {
|
||
const [x, y] = wellPos(well, frame);
|
||
const minStep = 0.6 / this.field;
|
||
if (!well.kernel || Math.abs(x - well.kx) > minStep || Math.abs(y - well.ky) > minStep) {
|
||
well.kernel = buildKernel(this.field, x, y, well.sigma);
|
||
well.kx = x;
|
||
well.ky = y;
|
||
let peak = 0;
|
||
for (let i = 0; i < well.kernel.w.length; i++) if (well.kernel.w[i] > peak) peak = well.kernel.w[i];
|
||
well.peak = Math.max(1e-6, peak);
|
||
}
|
||
}
|
||
|
||
/** The wells alive at a frame, with their recharge envelope. */
|
||
livingWells(frame) {
|
||
const out = [];
|
||
for (const w of this.wells) {
|
||
const env = wellEnv(w, frame, this.fps);
|
||
if (env <= 0) continue;
|
||
this.refreshKernel(w, frame);
|
||
out.push({ well: w, env });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** A land-rush: a well is born — food floods in, the nearest body claims. */
|
||
wellBirth(well, frame, spawnOp = 1) {
|
||
const [x, y] = wellPos(well, frame);
|
||
this.splat(x, y, well.sigma * 1.6, 2.5);
|
||
const F = this.field;
|
||
const cell = Math.min(F - 1, (y * F) | 0) * F + Math.min(F - 1, (x * F) | 0);
|
||
const body = this.nearestBody(x, y);
|
||
const vs = this.program.events.voiceStrain;
|
||
const burst = (0.25 + 1.2 * spawnOp) * 3.0;
|
||
for (let s = 0; s < this.m; s++) {
|
||
this.pop[s][cell] += burst;
|
||
}
|
||
this.addAllegiance(cell, body, burst);
|
||
const ax = Math.max(0, Math.min(31, (x * 32) | 0));
|
||
const ay = Math.max(0, Math.min(31, (y * 32) | 0));
|
||
this.activity[ay * 32 + ax] += 4;
|
||
}
|
||
|
||
// ---- step ---------------------------------------------------------------
|
||
|
||
/** Frame setup shared by step() and the genesis epoch. */
|
||
framePrep(regime) {
|
||
const f = this.frame;
|
||
// The section's warmth: the diplomacy base bent by the peace operator.
|
||
const ops = regime.operators;
|
||
this.warmth = Math.max(-1, Math.min(1,
|
||
this.program.diplomacy.base * 0.6 + 0.35 * (ops.peace - 1)));
|
||
|
||
// Living wells: births fire their land-rush (as hard as the section's
|
||
// spawn operator allows); the well field (the coarse attraction the
|
||
// strains smell) is rebuilt for this frame.
|
||
for (const w of this.wells) {
|
||
if (f === w.born) this.wellBirth(w, f, ops.spawn);
|
||
}
|
||
const living = this.livingWells(f);
|
||
this.updateWellField(living);
|
||
this.updateAnchors(living);
|
||
return living;
|
||
}
|
||
|
||
step() {
|
||
this.frame++;
|
||
const f = this.frame;
|
||
const regime = this.regimeForFrame(f);
|
||
const cutoff = f - Math.ceil(this.fps * 1.6);
|
||
while (this.pulses.length && this.pulses[0].frame < cutoff) this.pulses.shift();
|
||
|
||
this.birthsThisFrame = 0;
|
||
this.deathsThisFrame = 0;
|
||
this.moveAccum = 0;
|
||
this.hungerMass = 0;
|
||
|
||
const living = this.framePrep(regime);
|
||
this.updateRockField();
|
||
|
||
this.inject(f, regime);
|
||
this.refreshRockMask();
|
||
for (let s = 0; s < this.substeps; s++) this.substep(regime);
|
||
this.decay(regime, living);
|
||
this.politics(regime, false);
|
||
this.record(f);
|
||
return regime;
|
||
}
|
||
|
||
/** Coarse attraction field of the living wells (the strains smell this). */
|
||
updateWellField(living) {
|
||
const FC = this.FC;
|
||
const fc = FC * FC;
|
||
this.wellField.fill(0);
|
||
for (const { well, env } of living) {
|
||
const [x, y] = wellPos(well, this.frame);
|
||
const bx = x * (FC - 1);
|
||
const by = y * (FC - 1);
|
||
const sig = Math.max(1.4, well.sigma * this.field * 0.5);
|
||
const r = Math.ceil(sig * 2.2);
|
||
const denom = 1 / (2 * sig * sig);
|
||
for (let dy = -r; dy <= r; dy++) {
|
||
const py = Math.round(by) + dy;
|
||
if (py < 0 || py >= FC) continue;
|
||
for (let dx = -r; dx <= r; dx++) {
|
||
const px = Math.round(bx) + dx;
|
||
if (px < 0 || px >= FC) continue;
|
||
const g = Math.exp(-(dx * dx + dy * dy) * denom) * env;
|
||
this.wellField[py * FC + px] += g * 0.12;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The far smell of stone: the rock field, box-blurred twice on the
|
||
* coarse grid. Adjacent-block gradients only pull at the rim; this is
|
||
* what draws a quarry crew across the map.
|
||
*/
|
||
updateRockField() {
|
||
const FC = this.FC;
|
||
const fc = FC * FC;
|
||
const f = this.rockField;
|
||
f.fill(0);
|
||
const rock = this.rock;
|
||
const coarseOf = this.coarseOf;
|
||
for (let i = 0; i < this.n; i++) f[coarseOf[i]] += rock[i];
|
||
for (let pass = 0; pass < 2; pass++) {
|
||
const prev = Float32Array.from(f);
|
||
for (let b = 0; b < fc; b++) {
|
||
f[b] = (prev[b] * 4 + prev[this.cUp[b]] + prev[this.cDown[b]] +
|
||
prev[this.cLeft[b]] + prev[this.cRight[b]]) / 8;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Home anchors: each strain is pulled to its nearest living well. */
|
||
updateAnchors(living) {
|
||
const FC = this.FC;
|
||
const fc = FC * FC;
|
||
const m = this.m;
|
||
const pull = 1.1;
|
||
for (let b = 0; b < fc; b++) {
|
||
const wx = (b % FC + 0.5) / FC;
|
||
const wy = (((b / FC) | 0) + 0.5) / FC;
|
||
let bx = 0;
|
||
let by = 0;
|
||
let bd = Infinity;
|
||
for (const { well } of living) {
|
||
const [x, y] = wellPos(well, this.frame);
|
||
const dx = x - wx;
|
||
const dy = y - wy;
|
||
const d2 = dx * dx + dy * dy;
|
||
if (d2 < bd) { bd = d2; bx = dx; by = dy; }
|
||
}
|
||
for (let s = 0; s < m; s++) {
|
||
const i = b;
|
||
if (bd < Infinity) {
|
||
const d = Math.sqrt(bd) + 1e-6;
|
||
const p = clamp01(1 - d / this.homeReach) * pull * this.homePull[s];
|
||
this.ancX[s][i] = (bx / d) * p;
|
||
this.ancY[s][i] = (by / d) * p;
|
||
} else {
|
||
this.ancX[s][i] = 0;
|
||
this.ancY[s][i] = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
inject(frame, regime) {
|
||
const events = this.song.index[frame];
|
||
if (!events) return;
|
||
const F = this.field;
|
||
const spawn = regime.operators.spawn;
|
||
const ops = regime.operators;
|
||
const vs = this.program.events.voiceStrain;
|
||
const splash = this.program.events.splash;
|
||
// The song spawns: how hard a hit births new life where it lands. The
|
||
// drop regime's spawn operator floods the ground with colonies; the
|
||
// intro barely seeds.
|
||
const scale = 0.4 + 1.1 * spawn;
|
||
for (const e of events) {
|
||
// Food where the track put it.
|
||
this.splat(e.x, e.y, e.spread, e.amount * scale);
|
||
const cell = Math.min(F - 1, (e.y * F) | 0) * F + Math.min(F - 1, (e.x * F) | 0);
|
||
const boost = (0.5 + 3.0 * e.strength) * scale;
|
||
const body = this.nearestBody(e.x, e.y);
|
||
const lead = vs[e.voice];
|
||
this.pop[lead][cell] += boost;
|
||
let spawned = boost;
|
||
for (let s = 0; s < this.m; s++) {
|
||
if (s !== lead) { this.pop[s][cell] += boost * splash[s]; spawned += boost * splash[s]; }
|
||
}
|
||
this.addAllegiance(cell, body, spawned);
|
||
const ax = Math.max(0, Math.min(31, (e.x * 32) | 0));
|
||
const ay = Math.max(0, Math.min(31, (e.y * 32) | 0));
|
||
this.activity[ay * 32 + ax] += 0.5 + 2 * e.strength;
|
||
// Rock follows the song's own weather: strong low and mid hits
|
||
// DROP stone where they land (and crush what they land on);
|
||
// strong high hits SHATTER it — the percussion breaks the rock.
|
||
if (e.strength > 0.6) {
|
||
const rockPol = this.program.rock;
|
||
if (e.voice === 2) {
|
||
this.rockSplat(e.x, e.y, e.spread * 1.4, -e.strength * 2.4 * rockPol.shatter * ops.erode);
|
||
} else {
|
||
this.rockFallen += e.strength * 2.8 * ops.rockfall * rockPol.drop;
|
||
this.rockSplat(e.x, e.y, e.spread * 1.3, e.strength * 2.8 * ops.rockfall * rockPol.drop);
|
||
}
|
||
this.activity[ay * 32 + ax] += 1.5;
|
||
}
|
||
// The clearest causality: a hit lands as a ring on the ground.
|
||
if (e.strength > 0.42) {
|
||
this.pulses.push({ x: e.x, y: e.y, frame, strength: e.strength, voice: e.voice });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run every strain's program on every decision block. Senses are read
|
||
* from the block sums + gradients — including the body the local
|
||
* organisms belong to (KIN), the other bodies (FOE), the living wells,
|
||
* and the section's PEACE. The MOVE actions become the advection forces;
|
||
* the others are stored for the reaction phase.
|
||
*/
|
||
runStrains(regime) {
|
||
const FC = this.FC;
|
||
const fc = FC * FC;
|
||
const blkRock = this.blkRock;
|
||
const blkGarden = this.blkGarden;
|
||
// The minds think slower than the world moves: programs run on a
|
||
// fraction of the substeps (their decisions persist in the action
|
||
// buffer), and empty ground thinks not at all.
|
||
const decide = (this.substepCount % this.vmEvery) === 0;
|
||
const m = this.m;
|
||
const blkPop = this.blkPop;
|
||
const blkTrail = this.blkTrail;
|
||
const blkBody = this.blkBody;
|
||
const blkR = this.blkR;
|
||
const blkTotal = this.blkTotal;
|
||
const domB = this.domB;
|
||
const strains = this.strains;
|
||
const ss = this.ss;
|
||
const gg = this.gg;
|
||
const act = this.scratch;
|
||
const mem = this.stMem;
|
||
const actBuf = this.stAct;
|
||
const cU = this.cUp;
|
||
const cD = this.cDown;
|
||
const cL = this.cLeft;
|
||
const cR = this.cRight;
|
||
const fxX = this.fxX;
|
||
const fxY = this.fxY;
|
||
const synScratch = this.synergy;
|
||
const wellField = this.wellField;
|
||
const now = this.frame / this.fps;
|
||
const start = regime.start;
|
||
const span = Math.max(0.001, regime.end - regime.start);
|
||
const progT = clamp01((now - start) / span);
|
||
const lawSpawn = regime.operators.spawn;
|
||
const peace = clamp01((this.warmth + 1) / 2);
|
||
const actFC = FC * FC;
|
||
|
||
const actBufAll = this.stAct;
|
||
for (let b = 0; b < fc; b++) {
|
||
const tot = blkTotal[b];
|
||
if (tot < 1e-4) {
|
||
// Empty ground: no mind, no force — just make sure no stale
|
||
// decision lingers in the action buffer.
|
||
if (decide) {
|
||
for (let s = 0; s < m; s++) {
|
||
const base = s * ACTION_COUNT * actFC + b;
|
||
for (let k = 0; k < ACTION_COUNT; k++) actBufAll[base + k * actFC] = 0;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
const ug = cU[b];
|
||
const dg = cD[b];
|
||
const lg = cL[b];
|
||
const rg = cR[b];
|
||
const r0 = blkR[b];
|
||
const dom = domB[b];
|
||
const domPres = dom >= 0 ? blkBody[dom][b] : 0;
|
||
|
||
// Gradients of food and total population at this block.
|
||
const gRx = blkR[rg] - blkR[lg];
|
||
const gRy = blkR[dg] - blkR[ug];
|
||
// Population pressure: crowded ground pushes outward. This is the
|
||
// disperse operator's physics — a saturated colony spills over
|
||
// its rim instead of packing to the ceiling and stalling.
|
||
const dgx = blkTotal[rg] - blkTotal[lg];
|
||
const dgy = blkTotal[dg] - blkTotal[ug];
|
||
const pressure = 0.022 * regime.operators.disperse * clamp01(tot / 12);
|
||
|
||
for (let s = 0; s < m; s++) {
|
||
const own = blkPop[s][b];
|
||
const kinPres = dom >= 0 ? Math.max(0, blkBody[dom][b] - own) : 0;
|
||
const foe = Math.max(0, tot - domPres);
|
||
if (!decide) {
|
||
// between decisions the strain coasts on its last one:
|
||
// forces already sit in fxX/fxY, actions in the buffer.
|
||
const syn = this.syn[s];
|
||
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0;
|
||
synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare;
|
||
continue;
|
||
}
|
||
const nb = (blkPop[s][ug] + blkPop[s][dg] + blkPop[s][lg] + blkPop[s][rg]) / 4;
|
||
|
||
// ---- senses -------------------------------------------------
|
||
ss[S_FOOD] = clamp01(r0 * 0.12);
|
||
ss[S_SELF] = clamp01(own * 0.008);
|
||
ss[S_KIN] = clamp01(kinPres * 0.008);
|
||
ss[S_FOE] = clamp01(foe * 0.008);
|
||
ss[S_TRAIL] = dom >= 0 ? clamp01(blkTrail[dom][b] * 0.05) : 0;
|
||
ss[S_LAW] = clamp01(lawSpawn / 2.2);
|
||
ss[S_HUNGER] = 1 - ss[S_FOOD];
|
||
ss[S_EDGE] = clamp01(Math.sign(own - nb) * 0.5 + 0.5);
|
||
ss[S_TIME] = progT;
|
||
ss[S_TOTAL] = clamp01(tot * 0.005);
|
||
ss[S_WELL] = clamp01(wellField[b]);
|
||
ss[S_PEACE] = peace;
|
||
// The work-senses look where the work points: the block the
|
||
// strain aimed at last substep (its own feet when it stood).
|
||
const aimIdx = s * fc + b;
|
||
const axn = this.aimX[aimIdx];
|
||
const ayn = this.aimY[aimIdx];
|
||
const wb = (axn || ayn)
|
||
? Math.min(FC - 1, Math.max(0, (b % FC) + axn)) +
|
||
FC * Math.min(FC - 1, Math.max(0, ((b / FC) | 0) + ayn))
|
||
: b;
|
||
ss[S_ROCK] = clamp01(blkRock[wb] * 0.45);
|
||
ss[S_GARDEN] = clamp01(blkGarden[wb] * 0.5);
|
||
|
||
// ---- gradients (sign per field × axis) ---------------------
|
||
gg[G_FOOD * 2] = Math.sign(gRx);
|
||
gg[G_FOOD * 2 + 1] = Math.sign(gRy);
|
||
gg[G_SELF * 2] = Math.sign(this.gradOf(blkPop[s], b, ug, dg, lg, rg));
|
||
gg[G_SELF * 2 + 1] = this.gradY(blkPop[s], b, ug, dg);
|
||
gg[G_KIN * 2] = 0;
|
||
gg[G_KIN * 2 + 1] = 0;
|
||
gg[G_FOE * 2] = 0;
|
||
gg[G_FOE * 2 + 1] = 0;
|
||
if (dom >= 0) {
|
||
gg[G_KIN * 2] = Math.sign(blkBody[dom][rg] - blkBody[dom][lg]);
|
||
gg[G_KIN * 2 + 1] = Math.sign(blkBody[dom][dg] - blkBody[dom][ug]);
|
||
// foe gradient: the other bodies' presence, summed.
|
||
let fx = 0;
|
||
let fy = 0;
|
||
for (const c of this.aliveBodies) {
|
||
if (c === dom) continue;
|
||
fx += blkBody[c][rg] - blkBody[c][lg];
|
||
fy += blkBody[c][dg] - blkBody[c][ug];
|
||
}
|
||
gg[G_FOE * 2] = Math.sign(fx);
|
||
gg[G_FOE * 2 + 1] = Math.sign(fy);
|
||
}
|
||
gg[G_WELL * 2] = Math.sign(wellField[rg] - wellField[lg]);
|
||
gg[G_WELL * 2 + 1] = Math.sign(wellField[dg] - wellField[ug]);
|
||
gg[G_ROCK * 2] = Math.sign(this.rockField[rg] - this.rockField[lg]);
|
||
gg[G_ROCK * 2 + 1] = Math.sign(this.rockField[dg] - this.rockField[ug]);
|
||
gg[G_GARDEN * 2] = Math.sign(blkGarden[rg] - blkGarden[lg]);
|
||
gg[G_GARDEN * 2 + 1] = Math.sign(blkGarden[dg] - blkGarden[ug]);
|
||
|
||
const flat = ensureFlat(strains[s]);
|
||
runProgram(this.stMem, s * REGS * FC + b, ss, gg, act, flat._flatI, flat._flatF, flat._flatN);
|
||
// Remember the aim: the next substep's work-senses look there.
|
||
let nx = act[12] !== 0 ? Math.sign(act[12]) : Math.sign(act[0]);
|
||
let ny = act[13] !== 0 ? Math.sign(act[13]) : Math.sign(act[1]);
|
||
this.aimX[aimIdx] = nx;
|
||
this.aimY[aimIdx] = ny;
|
||
|
||
// ---- actions → forces + reaction mods ----------------------
|
||
fxX[s][b] = act[0] + this.ancX[s][b] * 0.12 + gRx * 0.02 - dgx * pressure;
|
||
fxY[s][b] = act[1] + this.ancY[s][b] * 0.12 + gRy * 0.02 - dgy * pressure;
|
||
const off = s * ACTION_COUNT * actFC + b;
|
||
actBuf[off + 2 * actFC] = act[2]; // EXCRETE
|
||
actBuf[off + 3 * actFC] = act[3]; // EAT
|
||
actBuf[off + 4 * actFC] = act[4]; // HIT
|
||
actBuf[off + 5 * actFC] = act[5]; // SPAWN
|
||
actBuf[off + 6 * actFC] = act[6]; // DIG
|
||
actBuf[off + 7 * actFC] = act[7]; // BUILD
|
||
actBuf[off + 8 * actFC] = act[8]; // TILL
|
||
actBuf[off + 9 * actFC] = act[9]; // TEND
|
||
actBuf[off + 12 * actFC] = act[12]; // AIM_X
|
||
actBuf[off + 13 * actFC] = act[13]; // AIM_Y
|
||
|
||
// Synergy: this strain breeds fastest in a mixed, same-body
|
||
// crowd — the share of the block's population that is kin.
|
||
const syn = this.syn[s];
|
||
const kinShare = dom >= 0 ? clamp01((blkBody[dom][b] - own) / Math.max(1e-6, syn.threshold)) : 0;
|
||
synScratch[s * FC + b] = syn.base + (1 - syn.base) * kinShare;
|
||
}
|
||
}
|
||
}
|
||
|
||
gradOf(arr, b, ug, dg, lg, rg) { return Math.sign(arr[rg] - arr[lg]); }
|
||
gradY(arr, b, ug, dg) { return Math.sign(arr[dg] - arr[ug]); }
|
||
|
||
/**
|
||
* One simulation substep:
|
||
* 1. block sums (food, strains, body presence, dominant bodies);
|
||
* 2. the strain programs decide the forces;
|
||
* 3. advection + diffusion on the fine field (strains by their own
|
||
* force; allegiance frozen-in with the population flux);
|
||
* 4. reaction: eat → grow (with synergy) → war (casualties +
|
||
* conversion) → deposit into the body's trail.
|
||
* Deterministic throughout: the only randomness is the static noise field.
|
||
*/
|
||
substep(regime) {
|
||
this.substepCount++;
|
||
const F = this.field;
|
||
const FC = this.FC;
|
||
const fc = FC * FC;
|
||
const sub = 1 / (this.fps * this.substeps);
|
||
const ops = regime.operators;
|
||
const prog = this.program;
|
||
const strains = prog.strains;
|
||
const m = this.m;
|
||
const diff = prog.memory.diffusion;
|
||
const jitterAmt = prog.memory.jitter * (0.4 + 1.35 * ops.turbulence);
|
||
const capMul = 0.7 + 0.6 * ops.split;
|
||
|
||
const pop = this.pop;
|
||
const next = this.popNext;
|
||
const alleg = this.alleg;
|
||
const allegNext = this.allegNext;
|
||
const trail = this.trail;
|
||
const res = this.resource;
|
||
const density = this.density;
|
||
const nUp = this.nUp;
|
||
const nDown = this.nDown;
|
||
const nLeft = this.nLeft;
|
||
const nRight = this.nRight;
|
||
const coarseOf = this.coarseOf;
|
||
const noise = this.noise;
|
||
const noise2 = this.noise2;
|
||
const n = this.n;
|
||
const rock = this.rock;
|
||
|
||
// ---- 1. block sums ----------------------------------------------------
|
||
const blkR = this.blkR;
|
||
const blkPop = this.blkPop;
|
||
const blkTrail = this.blkTrail;
|
||
const blkBody = this.blkBody;
|
||
const blkTotal = this.blkTotal;
|
||
const blkRock = this.blkRock;
|
||
const blkGarden = this.blkGarden;
|
||
blkR.fill(0);
|
||
blkTotal.fill(0);
|
||
blkRock.fill(0);
|
||
blkGarden.fill(0);
|
||
for (let s = 0; s < m; s++) blkPop[s].fill(0);
|
||
for (let b = 0; b < this.B; b++) { blkTrail[b].fill(0); blkBody[b].fill(0); }
|
||
for (let i = 0; i < n; i++) {
|
||
const b = coarseOf[i];
|
||
const d = density[i];
|
||
blkR[b] += res[i] + this.flora[i] * 0.5;
|
||
blkRock[b] += rock[i];
|
||
blkGarden[b] += this.garden[i];
|
||
if (d > 1e-6) {
|
||
blkTotal[b] += d;
|
||
for (let s = 0; s < m; s++) blkPop[s][b] += pop[s][i];
|
||
for (let c = 0; c < this.B; c++) {
|
||
const a = alleg[c][i];
|
||
if (a > 1e-6) {
|
||
blkBody[c][b] += a;
|
||
blkTrail[c][b] += trail[c][i];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Dominant + second body per block (canonical: ties → lowest id).
|
||
const domB = this.domB;
|
||
const subB = this.subB;
|
||
for (let b = 0; b < fc; b++) {
|
||
let d0 = -1;
|
||
let d1 = -1;
|
||
let v0 = 0;
|
||
let v1 = 0;
|
||
for (const c of this.aliveBodies) {
|
||
const v = blkBody[c][b];
|
||
if (v > v0) { v1 = v0; d1 = d0; v0 = v; d0 = c; }
|
||
else if (v > v1) { v1 = v; d1 = c; }
|
||
}
|
||
domB[b] = d0;
|
||
subB[b] = d1;
|
||
}
|
||
|
||
// ---- 2. the strain programs decide the forces --------------------------
|
||
this.runStrains(regime);
|
||
|
||
// The per-block war pressure: hits land at their AIM target — where
|
||
// the striker is heading, i.e. where the foe actually stands — and
|
||
// siege saps the stone there. War has a front line now.
|
||
const actBuf = this.stAct;
|
||
const actFC = FC * FC;
|
||
const hostility = this.hostility;
|
||
hostility.fill(0);
|
||
const hitAt = this.hitAt;
|
||
hitAt.fill(0);
|
||
const aimTarget = (b, s) => {
|
||
const base = s * ACTION_COUNT * actFC + b;
|
||
let ax = actBuf[base + 12 * actFC];
|
||
let ay = actBuf[base + 13 * actFC];
|
||
if (ax === 0) ax = actBuf[base + 0 * actFC]; // default: where we walk
|
||
if (ay === 0) ay = actBuf[base + 1 * actFC];
|
||
const sx = Math.sign(ax);
|
||
const sy = Math.sign(ay);
|
||
const bx = b % FC;
|
||
const by = (b / FC) | 0;
|
||
const nx = Math.min(FC - 1, Math.max(0, bx + sx));
|
||
const ny = Math.min(FC - 1, Math.max(0, by + sy));
|
||
return ny * FC + nx;
|
||
};
|
||
for (let b = 0; b < fc; b++) {
|
||
for (let s = 0; s < m; s++) {
|
||
const hits = actBuf[s * ACTION_COUNT * actFC + 4 * actFC + b];
|
||
if (hits > 1e-9) hitAt[aimTarget(b, s)] += hits;
|
||
}
|
||
}
|
||
for (let b = 0; b < fc; b++) {
|
||
const dom = domB[b];
|
||
const other = subB[b];
|
||
if (dom < 0 || other < 0) continue;
|
||
const dip = this.diplomacy(dom, other);
|
||
if (dip >= 0) continue;
|
||
const foeShare = clamp01(blkBody[other][b] / Math.max(1e-6, blkTotal[b]));
|
||
hostility[b] = Math.min(1, hitAt[b] * 8) * (-dip) * foeShare;
|
||
}
|
||
|
||
// ---- 3. advection + diffusion ------------------------------------------
|
||
// Allegiance rides the population flux: the density-weighted mean of
|
||
// the strains' velocities.
|
||
const velX = this.velX;
|
||
const velY = this.velY;
|
||
for (let b = 0; b < fc; b++) {
|
||
let sx = 0;
|
||
let sy = 0;
|
||
let w = 0;
|
||
for (let s = 0; s < m; s++) {
|
||
const p = blkPop[s][b];
|
||
sx += this.fxX[s][b] * p;
|
||
sy += this.fxY[s][b] * p;
|
||
w += p;
|
||
}
|
||
velX[b] = w > 1e-6 ? sx / w : 0;
|
||
velY[b] = w > 1e-6 ? sy / w : 0;
|
||
}
|
||
|
||
const advFrac = Math.min(0.4, this.avgSpeed * ops.speed * sub * 240);
|
||
for (let s = 0; s < m; s++) {
|
||
this.advect(pop[s], next[s], this.fxX[s], this.fxY[s],
|
||
advFrac, jitterAmt, diff, sub, true);
|
||
}
|
||
const carry = this.carry;
|
||
const allegDiff = diff; // allegiance is frozen-in: it travels exactly
|
||
// with the population's own spreading
|
||
for (let c = 0; c < this.B; c++) {
|
||
this.advect(alleg[c], allegNext[c], velX, velY, advFrac, 0, allegDiff, sub, false);
|
||
}
|
||
// The quarried burden rides the same flux — this is the hauling.
|
||
for (let s = 0; s < m; s++) {
|
||
this.advect(carry[s], this.carryNext[s], velX, velY, advFrac, 0, diff * 0.15, sub, false);
|
||
}
|
||
|
||
// ---- 4. react -----------------------------------------------------------
|
||
const societyCeil = prog.society.ceiling;
|
||
const globalCap = prog.society.global;
|
||
const synScratch = this.synergy;
|
||
const vals = this.valScratch;
|
||
const grown = this.growScratch;
|
||
// The dish's carrying capacity: as the whole world approaches it,
|
||
// breeding eases off everywhere (soft, so it never bites hard).
|
||
const globalDamp = Math.max(0, 1 - Math.max(0, this.totalPop - globalCap) / globalCap);
|
||
for (let i = 0; i < n; i++) {
|
||
const r0 = res[i];
|
||
const b = coarseOf[i];
|
||
const dom = domB[b];
|
||
if (r0 < 1e-6 && density[i] < 1e-6) continue;
|
||
|
||
let total = 0;
|
||
for (let s = 0; s < m; s++) vals[s] = next[s][i];
|
||
|
||
// Saturated food factor (rational approximation, no transcendentals).
|
||
const foodFactor = (r0 * 5) / (1 + r0 * 5);
|
||
this.hungerMass += density[i] * (1 - foodFactor);
|
||
// The gate is nearly shut on bare ground: food is the substrate, not a
|
||
// catalyst. A colony that outgrows its ground starves back and must
|
||
// wander — the world tracks its wells instead of congealing.
|
||
const gate = 0.03 + 0.97 * foodFactor;
|
||
|
||
// War: the block's contest bleeds the losing body's share here.
|
||
const host = hostility[b];
|
||
let casualty = 0;
|
||
if (host > 1e-9 && dom >= 0) {
|
||
const other = subB[b] >= 0 ? subB[b] : dom;
|
||
const loseShare = dom !== other
|
||
? clamp01(allegNext[other][i] / Math.max(1e-6, density[i]))
|
||
: 0;
|
||
casualty = Math.min(0.35, host * 2.5 * sub * loseShare);
|
||
}
|
||
|
||
let demand = 0;
|
||
for (let s = 0; s < m; s++) demand += vals[s] * this.eatR[s] * actBuf[s * ACTION_COUNT * actFC + 3 * actFC + b];
|
||
demand *= sub;
|
||
// The store runs out → the crowd mows the wild flora (half a
|
||
// stand at a time; it regrows if left alone).
|
||
const fromRes = Math.min(r0, demand);
|
||
// Grazing can only take a whisker of the stand per bite — a
|
||
// crowd mows a meadow over seconds, never in an instant.
|
||
const bite = Math.min(this.flora[i] * 0.004, demand - fromRes);
|
||
this.flora[i] -= bite;
|
||
res[i] = demand > 0
|
||
? Math.max(0, r0 - fromRes) + bite * 0.55
|
||
: r0;
|
||
|
||
const starve = (1 - foodFactor) * (0.28 + 0.9 * ops.spawn) * sub;
|
||
const society = Math.max(0, 1 - density[i] / societyCeil);
|
||
for (let s = 0; s < m; s++) {
|
||
const p = vals[s];
|
||
const spn = actBuf[s * ACTION_COUNT * actFC + 5 * actFC + b];
|
||
const synB = synScratch[s * FC + b];
|
||
let g = p * this.growth[s] * ops.growth * globalDamp * (1 + spn) * synB * sub * (1 - p / (this.caps[s] * capMul)) * gate;
|
||
g *= (1 - casualty * 8);
|
||
// Starvation is the body's metabolic floor: bare ground kills,
|
||
// slowly but surely — a colony that sits still on exhausted
|
||
// ground dissolves, and the survivors must wander. This is
|
||
// what keeps the world working instead of congealing. (The
|
||
// eat action drives depletion above; it is not the price of
|
||
// eating.)
|
||
g = Math.max(0, p + g * society) - p * starve * this.eatR[s];
|
||
g = Math.max(0, g);
|
||
if (casualty > 0) g = Math.max(0, g - g * casualty * 6); // war kills; it does not convert
|
||
grown[s] = g;
|
||
total += g;
|
||
}
|
||
|
||
// Deposit into the DOMINANT body's trail: each cell's organisms
|
||
// write the geography of the body they belong to.
|
||
if (total > 1e-9 && dom >= 0) {
|
||
const tB = trail[dom];
|
||
for (let s = 0; s < m; s++) {
|
||
const exc = actBuf[s * ACTION_COUNT * actFC + 2 * actFC + b];
|
||
tB[i] += grown[s] * this.depositR[s] * ops.deposit * (1 + exc) * sub * DEPOSIT_SCALE;
|
||
}
|
||
}
|
||
|
||
let dNew = 0;
|
||
for (let s = 0; s < m; s++) {
|
||
const before = pop[s][i];
|
||
const after = grown[s];
|
||
next[s][i] = after;
|
||
dNew += after;
|
||
if (after > before) this.birthsThisFrame += after - before;
|
||
else if (after < before) this.deathsThisFrame += before - after;
|
||
}
|
||
density[i] = dNew;
|
||
|
||
// The composition grows with the cell: newborn organisms inherit
|
||
// the standing mix; an allegiance-less cell joins the dominant
|
||
// body wholesale.
|
||
if (dNew > 1e-6) {
|
||
let S = 0;
|
||
for (let c = 0; c < this.B; c++) S += allegNext[c][i];
|
||
if (S > 1e-4) {
|
||
const k = dNew / S;
|
||
for (let c = 0; c < this.B; c++) allegNext[c][i] *= k;
|
||
} else if (dom >= 0) {
|
||
for (let c = 0; c < this.B; c++) allegNext[c][i] = 0;
|
||
allegNext[dom][i] = dNew;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- swap ---------------------------------------------------------------
|
||
let totalPop = 0;
|
||
for (let s = 0; s < m; s++) {
|
||
const t = pop[s];
|
||
pop[s] = next[s];
|
||
next[s] = t;
|
||
const p = pop[s];
|
||
for (let i = 0; i < n; i++) totalPop += p[i];
|
||
}
|
||
for (let c = 0; c < this.B; c++) {
|
||
const t = alleg[c];
|
||
alleg[c] = allegNext[c];
|
||
allegNext[c] = t;
|
||
}
|
||
for (let s = 0; s < m; s++) {
|
||
const t = carry[s];
|
||
carry[s] = this.carryNext[s];
|
||
this.carryNext[s] = t;
|
||
}
|
||
this.totalPop = totalPop;
|
||
// ---- rock economy: quarries and walls -----------------------------------
|
||
// Digging takes effort (a slow bite out of the stone) and yields
|
||
// soil; the burden rides in the block's carry until the program
|
||
// builds with it. This is how the world moves its own ground around.
|
||
const rockPol = prog.rock;
|
||
const mask = this.rockMask;
|
||
const bs = F / FC;
|
||
const cells = bs * bs;
|
||
for (let b = 0; b < fc; b++) {
|
||
for (let s = 0; s < m; s++) {
|
||
const base = s * ACTION_COUNT * actFC + b;
|
||
const dig = actBuf[base + 6 * actFC];
|
||
const build = actBuf[base + 7 * actFC];
|
||
const till = actBuf[base + 8 * actFC];
|
||
const tend = actBuf[base + 9 * actFC];
|
||
if (dig <= 1e-6 && build <= 1e-6 && till <= 1e-6 && tend <= 1e-6) continue;
|
||
// The work lands AHEAD: at the block the aim points at.
|
||
const tb = aimTarget(b, s);
|
||
const bx = (tb % FC) * bs;
|
||
const by = ((tb / FC) | 0) * bs;
|
||
if (till > 1e-6) {
|
||
// Work the stone into beds — one cell brought to
|
||
// maturity before the next is started (a scattered
|
||
// dusting of fertility would exhaust as fast as it is
|
||
// laid and mint stone from nothing). The garden's own
|
||
// production is the payoff; work yields no food.
|
||
let want = till * rockPol.till * 3.0 * sub;
|
||
for (let y = by; y < by + bs; y++) {
|
||
for (let x = bx; x < bx + bs; x++) {
|
||
if (want <= 1e-9) break;
|
||
const i = y * F + x;
|
||
if (this.garden[i] >= 0.2) continue; // already a bed
|
||
const room = Math.min(rock[i], 0.6 - this.garden[i]);
|
||
if (room <= 1e-6) continue;
|
||
const t = Math.min(room, want);
|
||
rock[i] -= t;
|
||
this.garden[i] += t;
|
||
want -= t;
|
||
}
|
||
}
|
||
}
|
||
if (dig > 1e-6) {
|
||
let want = dig * rockPol.dig * 2.0 * sub;
|
||
for (let y = by; y < by + bs; y++) {
|
||
for (let x = bx; x < bx + bs; x++) {
|
||
if (want <= 1e-9) break;
|
||
const i = y * F + x;
|
||
const t = Math.min(rock[i], want / cells);
|
||
if (t <= 0) continue;
|
||
rock[i] -= t;
|
||
want -= t;
|
||
carry[s][i] += t * 0.9; // shouldered burden
|
||
}
|
||
}
|
||
}
|
||
if (tend > 1e-6) {
|
||
// Tended beds yield far more: the husbandman's visit
|
||
// raises the garden's vigour for a while.
|
||
for (let y = by; y < by + bs; y++) {
|
||
for (let x = bx; x < bx + bs; x++) {
|
||
const i = y * F + x;
|
||
if (this.garden[i] < 0.02) continue;
|
||
this.tend[i] = Math.min(1, this.tend[i] + tend * sub * 2.5);
|
||
}
|
||
}
|
||
}
|
||
if (build > 1e-6) {
|
||
// The block's burden is pooled and mortared along a
|
||
// rounded footprint: walls rise organically.
|
||
const kernel = this.buildKernel[tb];
|
||
let avail = 0;
|
||
let room = 0;
|
||
for (let k = 0; k < kernel.idx.length; k++) {
|
||
const i = kernel.idx[k];
|
||
avail += carry[s][i];
|
||
room += kernel.w[k] * Math.max(0, 2.4 - rock[i]);
|
||
}
|
||
if (avail > 1e-9 && room > 1e-9) {
|
||
const deposit = Math.min(avail, build * rockPol.build * 4.0 * sub, room);
|
||
const takeK = deposit / avail;
|
||
for (let k = 0; k < kernel.idx.length; k++) {
|
||
const i = kernel.idx[k];
|
||
carry[s][i] *= 1 - takeK;
|
||
// each cell receives its weighted share of the
|
||
// room it still has
|
||
const roomK = kernel.w[k] * Math.max(0, 2.4 - rock[i]);
|
||
if (roomK > 0) rock[i] += deposit * roomK / room;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
}
|
||
|
||
/**
|
||
* Conservative L1 advection + diffusion of one field, shared by the
|
||
* strains (which track activity) and the allegiance composition. `frac`
|
||
* is the fraction of a cell that departs this substep.
|
||
*/
|
||
advect(src, dst, fxArr, fyArr, frac, jitterAmt, diff, sub, track) {
|
||
const F = this.field;
|
||
const nUp = this.nUp;
|
||
const nDown = this.nDown;
|
||
const nLeft = this.nLeft;
|
||
const nRight = this.nRight;
|
||
const coarseOf = this.coarseOf;
|
||
const noise = this.noise;
|
||
const noise2 = this.noise2;
|
||
const mask = this.rockMask;
|
||
const n = this.n;
|
||
dst.fill(0);
|
||
for (let i = 0; i < n; i++) {
|
||
const v = src[i];
|
||
if (v <= 1e-6) {
|
||
continue;
|
||
}
|
||
const b = coarseOf[i];
|
||
const vx = fxArr[b] + (track ? noise[i] * jitterAmt : 0);
|
||
const vy = fyArr[b] + (track ? noise2[i] * jitterAmt : 0);
|
||
const w = Math.abs(vx) + Math.abs(vy);
|
||
|
||
const leave = v * frac;
|
||
if (leave > 0 && w > 1e-6) {
|
||
const mx = (leave * Math.abs(vx)) / w;
|
||
const my = (leave * Math.abs(vy)) / w;
|
||
dst[i] += v - leave;
|
||
// Rock is a wall: mass aimed at a stone cell stays put and
|
||
// piles against it.
|
||
const tx = vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i;
|
||
const ty = vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i;
|
||
dst[mask[tx] ? i : tx] += mx;
|
||
dst[mask[ty] ? i : ty] += my;
|
||
|
||
if (track) {
|
||
const ax = ((i % F) * 32 / F) | 0;
|
||
const ay = ((i / F) * 32 / F) | 0;
|
||
this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6;
|
||
this.moveAccum += leave;
|
||
}
|
||
} else {
|
||
dst[i] += v;
|
||
}
|
||
|
||
// Diffusion through the OPEN neighbours only: rock reflects the
|
||
// haze instead of drinking it (still conservative overall).
|
||
let open = 0;
|
||
let nb = 0;
|
||
if (!mask[nUp[i]]) { nb += src[nUp[i]]; open++; }
|
||
if (!mask[nDown[i]]) { nb += src[nDown[i]]; open++; }
|
||
if (!mask[nLeft[i]]) { nb += src[nLeft[i]]; open++; }
|
||
if (!mask[nRight[i]]) { nb += src[nRight[i]]; open++; }
|
||
if (open > 0) {
|
||
const d = (nb - open * v) * diff * sub;
|
||
dst[i] += d * 0.5;
|
||
const share = d * 0.5 / open;
|
||
if (!mask[nUp[i]]) dst[nUp[i]] -= share;
|
||
if (!mask[nDown[i]]) dst[nDown[i]] -= share;
|
||
if (!mask[nLeft[i]]) dst[nLeft[i]] -= share;
|
||
if (!mask[nRight[i]]) dst[nRight[i]] -= share;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- the politics of allegiance (frame level) ----------------------------
|
||
|
||
/**
|
||
* Allegiance changes here — never inside a substep:
|
||
* defection a body whose presence collapsed releases its members to
|
||
* the strongest neighbour, cell by cell;
|
||
* fission a coherent, mixed, cut-off splinter hit by a strong onset
|
||
* founds a new body (a schism: rival to its parent);
|
||
* merger two intermixed bodies under a cooperative regime unify.
|
||
*/
|
||
politics(regime, silent) {
|
||
const n = this.n;
|
||
const F = this.field;
|
||
const density = this.density;
|
||
const alleg = this.alleg;
|
||
const f = this.frame;
|
||
void silent;
|
||
|
||
// ---- the composition tracks the population: scale each cell's
|
||
// allegiance to its actual population, claim frontier cells for the
|
||
// nearest body, and fade the claims of abandoned ground.
|
||
for (let i = 0; i < n; i++) {
|
||
const target = density[i];
|
||
let S = 0;
|
||
for (const b of this.aliveBodies) {
|
||
if (alleg[b][i] < 0) alleg[b][i] = 0; // advection overshoot
|
||
S += alleg[b][i];
|
||
}
|
||
if (target > 1e-6) {
|
||
if (S > 1e-4) {
|
||
const k = Math.min(1e4, target / S);
|
||
for (const b of this.aliveBodies) {
|
||
alleg[b][i] = Math.min(target, alleg[b][i] * k);
|
||
}
|
||
} else {
|
||
const x = (i % F) / F;
|
||
const y = ((i / F) | 0) / F;
|
||
let best = this.aliveBodies[0];
|
||
let bd = Infinity;
|
||
for (const b of this.aliveBodies) {
|
||
const d = Math.hypot(this.bodies[b].cx - x, this.bodies[b].cy - y);
|
||
if (d < bd - 1e-9) { bd = d; best = b; }
|
||
}
|
||
if (best !== undefined) {
|
||
for (let c = 0; c < this.B; c++) alleg[c][i] = 0;
|
||
alleg[best][i] = target;
|
||
}
|
||
}
|
||
} else if (S > 1e-9) {
|
||
// the population left: the claim fades
|
||
for (const b of this.aliveBodies) alleg[b][i] *= 0.82;
|
||
}
|
||
}
|
||
|
||
// Presence per body + weighted centroid.
|
||
for (const b of this.aliveBodies) {
|
||
const body = this.bodies[b];
|
||
body.presence = 0;
|
||
}
|
||
const presX = new Float64Array(this.B);
|
||
const presY = new Float64Array(this.B);
|
||
for (let i = 0; i < n; i++) {
|
||
for (const b of this.aliveBodies) {
|
||
const a = alleg[b][i];
|
||
if (a > 1e-6) {
|
||
this.bodies[b].presence += a;
|
||
presX[b] += (i % F) * a;
|
||
presY[b] += ((i / F) | 0) * a;
|
||
}
|
||
}
|
||
}
|
||
for (const b of this.aliveBodies) {
|
||
const body = this.bodies[b];
|
||
body.cx = body.presence > 1e-6 ? presX[b] / body.presence / F : body.cx;
|
||
body.cy = body.presence > 1e-6 ? presY[b] / body.presence / F : body.cy;
|
||
body.peak = Math.max(body.peak, body.presence);
|
||
}
|
||
|
||
// ---- colonisation: a settlement far from every body core, blessed
|
||
// by a strong onset, founds a NEW body. Rare, and it never flips an
|
||
// existing body's ground — it plants a new flag on the frontier.
|
||
if (f > 0 && f % COLONIZE_EVERY === 0 && this.aliveBodies.length < this.B) {
|
||
const FC = this.FC;
|
||
const fc = FC * FC;
|
||
let best = -1;
|
||
let bestScore = 0;
|
||
for (let b = 0; b < fc; b++) {
|
||
const tot = this.blkTotal[b];
|
||
if (tot < 10) continue;
|
||
const wx = ((b % FC) + 0.5) / FC;
|
||
const wy = (((b / FC) | 0) + 0.5) / FC;
|
||
// Far from EVERY living body: virgin ground, not a border.
|
||
let nearest = Infinity;
|
||
for (const c of this.aliveBodies) {
|
||
nearest = Math.min(nearest, Math.hypot(this.bodies[c].cx - wx, this.bodies[c].cy - wy));
|
||
}
|
||
if (nearest < 0.22) continue;
|
||
const score = tot * Math.min(1, nearest / 0.35);
|
||
if (score > bestScore) { bestScore = score; best = b; }
|
||
}
|
||
// A founding is blessed by a strong onset nearby, recent.
|
||
if (best >= 0 && bestScore > 8.0) {
|
||
const FCs = this.FC;
|
||
const wx = ((best % FCs) + 0.5) / FCs;
|
||
const wy = (((best / FCs) | 0) + 0.5) / FCs;
|
||
let blessed = false;
|
||
for (const p of this.pulses) {
|
||
if (p.strength > 0.7 && Math.hypot(p.x - wx, p.y - wy) < 0.14) { blessed = true; break; }
|
||
}
|
||
if (blessed) this.foundBody(best, f);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Found a new body from the splinter at coarse block `block`. */
|
||
foundBody(block, frame) {
|
||
const slot = this.bodies.findIndex((b) => !b.alive);
|
||
if (slot < 0) return -1;
|
||
const F = this.field;
|
||
const FC = this.FC;
|
||
const fc = FC * FC;
|
||
const body = this.bodies[slot];
|
||
const usedColors = new Set(this.aliveBodies.map((b) => this.bodies[b].color));
|
||
let color = 0;
|
||
while (usedColors.has(color) && color < this.B) color++;
|
||
body.alive = true;
|
||
body.founded = frame;
|
||
body.parent = -1;
|
||
body.color = Math.min(color, this.B - 1);
|
||
body.cx = ((block % FC) + 0.5) / FC;
|
||
body.cy = (((block / FC) | 0) + 0.5) / FC;
|
||
body.peak = 0;
|
||
body.presence = 0;
|
||
this.aliveBodies.push(slot);
|
||
// A new polity has no friends yet: wary neutrality toward everyone.
|
||
const alleg = this.alleg;
|
||
const bs = F / FC;
|
||
const bx = (block % FC) * bs;
|
||
const by = ((block / FC) | 0) * bs;
|
||
for (let y = by - 2; y < by + bs + 2; y++) {
|
||
for (let x = bx - 2; x < bx + bs + 2; x++) {
|
||
if (x < 0 || y < 0 || x >= F || y >= F) continue;
|
||
const i = y * F + x;
|
||
let S = 0;
|
||
for (let c = 0; c < this.B; c++) S += alleg[c][i];
|
||
if (S < 1e-6) continue;
|
||
// The settlement secedes: 65% of the ground's population
|
||
// takes the new flag.
|
||
for (let c = 0; c < this.B; c++) alleg[c][i] *= 0.35;
|
||
alleg[slot][i] += 0.65 * S;
|
||
}
|
||
}
|
||
for (const c of this.aliveBodies) {
|
||
if (c === slot) continue;
|
||
this.bodyAff[slot][c] = -0.15;
|
||
this.bodyAff[c][slot] = -0.15;
|
||
}
|
||
return slot;
|
||
}
|
||
|
||
// ---- decay & record ------------------------------------------------------
|
||
|
||
decay(regime, living) {
|
||
const dt = this.dt;
|
||
const memory = this.program.memory;
|
||
const ops = regime.operators;
|
||
const fieldK = Math.pow(0.5, dt / (memory.fieldHalfLife * (0.8 + 0.4 * ops.spawn)));
|
||
const trailK = Math.pow(0.5, dt / (memory.trailHalfLife * (0.75 + 0.35 * ops.deposit)));
|
||
const res = this.resource;
|
||
for (let i = 0; i < res.length; i++) res[i] *= fieldK;
|
||
for (let c = 0; c < this.B; c++) {
|
||
const t = this.trail[c];
|
||
for (let i = 0; i < t.length; i++) t[i] *= trailK;
|
||
}
|
||
const act = this.activity;
|
||
const actK = Math.pow(0.5, dt / 1.4);
|
||
for (let i = 0; i < act.length; i++) act[i] *= actK;
|
||
|
||
// Erosion: the section's erode operator sets how fast the stone
|
||
// weathers away. Breakdowns clear the ground; drops raise it. Even
|
||
// walls weather — unmaintained works crumble, so the masons are
|
||
// never done.
|
||
const rock = this.rock;
|
||
if (this.dbgRock) {
|
||
let before = 0; for (let i = 0; i < rock.length; i++) before += rock[i];
|
||
this.dbgBefore = before;
|
||
}
|
||
const rockHalf = 80 / (0.3 + 0.7 * ops.erode);
|
||
const rockK = Math.pow(0.5, dt / rockHalf);
|
||
for (let i = 0; i < rock.length; i++) rock[i] *= rockK;
|
||
|
||
// The flora is the world's pioneer, rooted in ISLANDS: it can only
|
||
// ever live on its own seed ground. Its vigour tracks the famine —
|
||
// a starving or dwindling world blooms with it (the seed-rain is
|
||
// the lifeline); a strong, well-fed world grazes it back to a
|
||
// whisper. The map keeps its green archipelago either way.
|
||
const famine = this.famine;
|
||
const popWeak = clamp01(1 - this.totalPop / Math.max(1, this.peakPop * 0.7));
|
||
const bloom = clamp01(famine * 0.6 + popWeak * 0.6);
|
||
const flora = this.flora;
|
||
const mask = this.rockMask;
|
||
const seeds = this.floraSeed;
|
||
for (let i = 0; i < flora.length; i++) {
|
||
if (mask[i] || !seeds[i]) { flora[i] = 0; continue; }
|
||
const f = flora[i];
|
||
flora[i] = Math.min(0.22, f + (0.008 * bloom + f * (1 - f / 0.22)) * (0.05 + 0.9 * bloom) * dt);
|
||
res[i] = Math.min(1.6, res[i] + f * 0.02 * dt);
|
||
}
|
||
|
||
// The gardens feed the world: cultivated ground yields a few units
|
||
// over its life, and spent ground petrifies back to rock.
|
||
const garden = this.garden;
|
||
const tendF = this.tend;
|
||
const tendK = Math.pow(0.5, dt / 4);
|
||
for (let i = 0; i < garden.length; i++) {
|
||
tendF[i] *= tendK;
|
||
const g = garden[i];
|
||
if (g < 0.01) continue;
|
||
const make = g * 0.045 * (1 + 2 * tendF[i]) * dt;
|
||
res[i] = Math.min(1.6, res[i] + make * 1.2);
|
||
garden[i] = g - make;
|
||
if (garden[i] < 0.03) {
|
||
// The bed is spent: the worked soil sets back into stone —
|
||
// almost all of it. Agriculture slowly eats the world's rock.
|
||
const back = garden[i] * 0.85 + 0.15;
|
||
rock[i] += back;
|
||
garden[i] = 0;
|
||
if (this.gardenSrc) this.gardenSrc[i] += back;
|
||
}
|
||
}
|
||
|
||
if (this.dbgRock) {
|
||
let after = 0; for (let i = 0; i < rock.length; i++) after += rock[i];
|
||
let g2r = 0;
|
||
for (let i = 0; i < this.garden.length; i++) if (this.gardenSrc[i] > 0) g2r += this.gardenSrc[i];
|
||
console.log('rockΔ this frame:', (after - this.dbgBefore).toFixed(1), '(garden-petrify portion:', g2r.toFixed(1), ')');
|
||
this.gardenSrc.fill(0);
|
||
}
|
||
|
||
// Wells recharge to a ceiling — but not through stone: rock capped
|
||
// ground stays barren until someone quarries it open.
|
||
const scale = (0.22 + 0.7 * regime.energy) * dt;
|
||
for (const { well, env } of living) {
|
||
const add = well.rate * scale * env / well.peak;
|
||
const idx = well.kernel.idx;
|
||
const w = well.kernel.w;
|
||
for (let i = 0; i < idx.length; i++) {
|
||
const cell = idx[i];
|
||
if (mask[cell]) continue;
|
||
res[cell] = Math.min(1.6, res[cell] + add * w[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
record(f) {
|
||
// The famine signal: mean hunger of the living, smoothed; and the
|
||
// population's high-water mark (slowly forgotten, so a crash can
|
||
// ask the pioneers for help again).
|
||
const meanHunger = this.totalPop > 1 ? Math.min(1, this.hungerMass / this.totalPop) : 1;
|
||
this.famine += (meanHunger - this.famine) * 0.03;
|
||
this.peakPop = Math.max(this.totalPop, this.peakPop * 0.9995);
|
||
|
||
if (f < 0 || f >= this.history.alive.length) return;
|
||
const h = this.history;
|
||
h.alive[f] = this.totalPop;
|
||
h.movement[f] = this.moveAccum;
|
||
let food = 0;
|
||
const res = this.resource;
|
||
for (let i = 0; i < res.length; i += 3) food += res[i];
|
||
h.food[f] = (food * 3) / res.length;
|
||
h.births[f] = this.birthsThisFrame;
|
||
h.deaths[f] = this.deathsThisFrame;
|
||
h.bodies[f] = this.aliveBodies.length;
|
||
}
|
||
|
||
/** The camera's view of where something just happened. */
|
||
activityCell(x, y) {
|
||
const ax = Math.max(0, Math.min(31, (x * 32) | 0));
|
||
const ay = Math.max(0, Math.min(31, (y * 32) | 0));
|
||
return this.activity[ay * 32 + ax];
|
||
}
|
||
|
||
stats() {
|
||
const h = this.history;
|
||
let peak = 0;
|
||
for (let i = 0; i < h.alive.length; i++) peak = Math.max(peak, h.alive[i]);
|
||
return {
|
||
alive: Math.round(this.totalPop),
|
||
peak,
|
||
fieldMass: this.sampleFieldMass(),
|
||
trailMass: this.sampleTrailMass(),
|
||
strains: this.strains.map((s) => s.name),
|
||
bodies: this.aliveBodies.length,
|
||
name: this.genome.name,
|
||
};
|
||
}
|
||
|
||
sampleFieldMass() {
|
||
let s = 0;
|
||
const res = this.resource;
|
||
for (let i = 0; i < res.length; i++) s += res[i];
|
||
return s / res.length;
|
||
}
|
||
|
||
sampleTrailMass() {
|
||
let s = 0;
|
||
for (let c = 0; c < this.trail.length; c++) {
|
||
const t = this.trail[c];
|
||
for (let i = 0; i < t.length; i++) s += t[i];
|
||
}
|
||
return s / (this.trail[0].length * this.trail.length);
|
||
}
|
||
}
|