← Natura·Docs/Modules/Source

organisms-test.js

92 lines · in natura-src-1.0.0.zip as natura/organisms-test.js · a test

// organisms-test.js -- assertions for the GENERAL organisms life system (entities wear the shared verbs, render-free,
// species-registry-driven, couples via ctx.fields). Test-rich because it is shared framework code. Run: node organisms-test.js
const N = require('./natura.js');
const { makeOrganisms } = require('./organisms.js');
const { rngFrom, makeClock, UNITS, World } = N;
const DAY = UNITS.DAY;

let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log('  FAIL: ' + m); } };
const ctxAt = (dp, fields) => ({ clock: makeClock(dp * DAY), fields: fields || {} });

console.log('organisms assertions:');

// a toy species registry: a phototroph PLANT, a diurnal GRAZER that flees wolves + forages plants, a diurnal WOLF.
const SPECIES = {
  plant:  { chronotype: 'phototroph', count: 3, band: [0.4, 0.6], spawn: e => { e.state.nectar = 1; } },
  grazer: { chronotype: 'diurnal', count: 2, band: [0.2, 0.4], behave: (e, api) => {
      const threats = api.of('wolf').map(w => ({ x: w.pos[0], y: w.pos[1] }));
      const fl = api.Verbs.flee({ x: e.pos[0], y: e.pos[1] }, threats, { near: 0.15 });
      if (fl.fleeing) { e.pos[0] += fl.fx * 0.05 * api.dt; e.pos[1] += fl.fy * 0.05 * api.dt; return; }   // fear beats hunger
      const src = api.of('plant').map(p => ({ x: p.pos[0], y: p.pos[1], amount: p.state.nectar }));
      const fr = api.Verbs.forage({ x: e.pos[0], y: e.pos[1] }, src, { reach: 0.02 });
      if (fr.target) { e.pos[0] += Math.sign(fr.target.x - e.pos[0]) * 0.05 * api.dt; e.pos[1] += Math.sign(fr.target.y - e.pos[1]) * 0.05 * api.dt; }
    } },
  wolf:   { chronotype: 'diurnal', count: 1, band: [0.2, 0.4] }
};
const build = () => makeOrganisms({ species: SPECIES });

// 1. INIT: counts, entity shape (layered state-bag), band, spawn hook
{ const sys = build(); const st = sys.init(rngFrom(3), sys.params, makeClock(0));
  ok(st.entities.length === 6, 'spawns all species (3 plant + 2 grazer + 1 wolf = 6)');
  ok(sys.fields(st).of('plant').length === 3, 'fields.of(name) returns a species group');
  const p = st.bySpecies.plant[0];
  ok(p.kind === 'organism' && p.species === 'plant' && p.chronotype === 'phototroph', 'entity is a layered state-bag carrying its chronotype');
  ok(p.pos[1] >= 0.4 && p.pos[1] <= 0.6, 'entity spawns within its species band');
  ok(p.state.nectar === 1, 'species spawn() hook set custom entity state');
}

// 2. ACTIVITY from chronotype against the clock sun -- a PLANT wears the same verb as animals
{ const sys = build(); const st = sys.init(rngFrom(3), sys.params, makeClock(0));
  sys.step(st, ctxAt(0.5), 0.1); const noon = st.bySpecies.plant[0].state.active;
  sys.step(st, ctxAt(0.0), 0.1); const midn = st.bySpecies.plant[0].state.active, wolfNight = st.bySpecies.wolf[0].state.active;
  ok(noon > 0.9 && midn < 0.1, 'phototroph plant: open by day (' + noon.toFixed(2) + '), shut by night (' + midn.toFixed(2) + ')');
  ok(wolfNight < 0.1, 'diurnal wolf sleeps at midnight');
}

// 3. DETERMINISM: same seed -> identical positions after K steps
{ const a = build(), b = build();
  const sa = a.init(rngFrom(9), a.params, makeClock(0)), sb = b.init(rngFrom(9), b.params, makeClock(0));
  for (let i = 0; i < 20; i++) { a.step(sa, ctxAt(0.5), 1); b.step(sb, ctxAt(0.5), 1); }
  let same = true; for (let k = 0; k < sa.entities.length; k++) if (sa.entities[k].pos[0] !== sb.entities[k].pos[0] || sa.entities[k].pos[1] !== sb.entities[k].pos[1]) { same = false; break; }
  ok(same, 'same seed -> identical positions after 20 steps (reproducible)');
}

// 4. FORAGE (verb): a grazer moves TOWARD a plant
{ const sys = makeOrganisms({ species: { plant: { chronotype: 'phototroph', count: 1, band: [0.5, 0.5], spawn: e => { e.state.nectar = 1; } }, grazer: SPECIES.grazer } });
  const st = sys.init(rngFrom(1), sys.params, makeClock(0));
  st.bySpecies.plant[0].pos = [0.5, 0.5]; st.bySpecies.grazer[0].pos = [0.5, 0.3];
  for (let i = 0; i < 8; i++) sys.step(st, ctxAt(0.5), 1);
  ok(st.bySpecies.grazer[0].pos[1] > 0.44, 'grazer forages TOWARD the plant (y 0.30 -> ' + st.bySpecies.grazer[0].pos[1].toFixed(2) + ')');
}

// 5. FLEE (verb): a grazer moves AWAY from a near wolf (fear beats forage)
{ const sys = build(); const st = sys.init(rngFrom(1), sys.params, makeClock(0));
  st.bySpecies.grazer[0].pos = [0.5, 0.3]; st.bySpecies.wolf[0].pos = [0.55, 0.3];
  const x0 = st.bySpecies.grazer[0].pos[0];
  for (let i = 0; i < 5; i++) sys.step(st, ctxAt(0.5), 1);
  ok(st.bySpecies.grazer[0].pos[0] < x0 - 0.02, 'grazer FLEES the near wolf (x ' + x0.toFixed(2) + ' -> ' + st.bySpecies.grazer[0].pos[0].toFixed(2) + ')');
}

// 6. STRUCTURE coupling: a field's sunAt(x,y) OVERRIDES the ambient sun (local light by place)
{ const sys = makeOrganisms({ species: { plant: { chronotype: 'phototroph', count: 1, band: [0.5, 0.5], spawn: () => {} } }, structure: 'canopy' });
  const st = sys.init(rngFrom(1), sys.params, makeClock(0));
  sys.step(st, { clock: makeClock(0.5 * DAY), fields: { canopy: { sunAt: () => -1 } } }, 0.1);   // noon, but the canopy says night here
  ok(st.bySpecies.plant[0].state.active < 0.1, 'structure sunAt(x,y) overrides ambient: a plant in canopy-night stays shut at noon');
}

// 7. CELESTIAL coupling: reads sin(sunAltitude) from a celestial field (the real sun, not the clock heuristic)
{ const sys = makeOrganisms({ species: { plant: { chronotype: 'phototroph', count: 1, band: [0.5, 0.5], spawn: () => {} } } });
  const st = sys.init(rngFrom(1), sys.params, makeClock(0));
  sys.step(st, { clock: makeClock(0), fields: { celestial: { sunAltitude: Math.PI / 2 } } }, 0.1);   // sun overhead
  ok(st.bySpecies.plant[0].state.active > 0.9, 'celestial sunAltitude=overhead -> plant active (couples to the real sun)');
}

// 8. COMPOSES IN A WORLD: runs as a Natura system, exposes fields through the World
{ const sys = build(); const w = World({ seed: 5, systems: [sys] });
  w.step(UNITS.HOUR * 3); const F = w.fields();
  ok(F.organisms && F.organisms.count === 6, 'organisms composes in a Natura World + exposes fields (count ' + (F.organisms && F.organisms.count) + ')');
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);