@fundamental-engine/core 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/agents/event-agent.d.ts +4 -2
  2. package/dist/agents/event-agent.d.ts.map +1 -1
  3. package/dist/agents/event-agent.js +15 -6
  4. package/dist/agents/event-agent.js.map +1 -1
  5. package/dist/core/currents.d.ts +12 -0
  6. package/dist/core/currents.d.ts.map +1 -1
  7. package/dist/core/currents.js +23 -0
  8. package/dist/core/currents.js.map +1 -1
  9. package/dist/core/events.d.ts +22 -0
  10. package/dist/core/events.d.ts.map +1 -1
  11. package/dist/core/events.js +52 -0
  12. package/dist/core/events.js.map +1 -1
  13. package/dist/core/field.d.ts.map +1 -1
  14. package/dist/core/field.js +537 -78
  15. package/dist/core/field.js.map +1 -1
  16. package/dist/core/frame-harness.d.ts +109 -0
  17. package/dist/core/frame-harness.d.ts.map +1 -0
  18. package/dist/core/frame-harness.js +300 -0
  19. package/dist/core/frame-harness.js.map +1 -0
  20. package/dist/core/host-headless.d.ts +35 -0
  21. package/dist/core/host-headless.d.ts.map +1 -0
  22. package/dist/core/host-headless.js +47 -0
  23. package/dist/core/host-headless.js.map +1 -0
  24. package/dist/core/integrator.d.ts +6 -0
  25. package/dist/core/integrator.d.ts.map +1 -1
  26. package/dist/core/integrator.js +62 -12
  27. package/dist/core/integrator.js.map +1 -1
  28. package/dist/core/streamlines.d.ts.map +1 -1
  29. package/dist/core/streamlines.js +18 -2
  30. package/dist/core/streamlines.js.map +1 -1
  31. package/dist/core/types.d.ts +88 -0
  32. package/dist/core/types.d.ts.map +1 -1
  33. package/dist/core/types.js +10 -1
  34. package/dist/core/types.js.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +5 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/record/index.d.ts +13 -0
  40. package/dist/record/index.d.ts.map +1 -0
  41. package/dist/record/index.js +13 -0
  42. package/dist/record/index.js.map +1 -0
  43. package/dist/record/record.d.ts +87 -0
  44. package/dist/record/record.d.ts.map +1 -0
  45. package/dist/record/record.js +172 -0
  46. package/dist/record/record.js.map +1 -0
  47. package/dist/record/rng.d.ts +15 -0
  48. package/dist/record/rng.d.ts.map +1 -0
  49. package/dist/record/rng.js +25 -0
  50. package/dist/record/rng.js.map +1 -0
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +1 -1
@@ -20,7 +20,7 @@ import { scanBodies, measureBodies, bodyFromElement } from "./scanner.js";
20
20
  import { ShadowRegistry, REGISTER_BODY, UNREGISTER_BODY, UPDATE_BODY, } from "./shadow.js";
21
21
  import { easeFormation } from "./formations.js";
22
22
  import { Heatmap } from "./heatmap.js";
23
- import { buildWaves, buildBound, waveYat, } from "./currents.js";
23
+ import { buildWaves, buildBound, waveYat, waveRAt, } from "./currents.js";
24
24
  import { healWaves, tearBoundNear, tearBoundByForces, induceCharges } from "./reservoir.js";
25
25
  import { FORMATION_BY, PALETTE } from "../config/forces.config.js";
26
26
  import { resolvePalette } from "../config/palettes.js";
@@ -30,11 +30,13 @@ import { feedbackTarget, feedbackWeight } from "./feedback.js";
30
30
  import { defaultFeedbackSink } from "./feedback-sink.js";
31
31
  import { thermoMetrics } from "./thermo.js";
32
32
  import { attentionMuls } from "./attention.js";
33
+ import { updateRelationship } from "../agents/relationship.js";
34
+ import { Thresholder } from "../agents/event-agent.js";
33
35
  import { spillover } from "./causality.js";
34
36
  import { integrateOffset, anchorForce, elementMass, repelForce, densityPush } from "./agents.js";
35
37
  import { releaseCaptured, sinkLoad, captureEdge, dischargeDisengaged } from "./accretion.js";
36
38
  import { withinCapture, stepDock, dockTransform } from "./dock.js";
37
- import { parseEventBindings, triggerActive } from "./events.js";
39
+ import { FieldEventCoalescer, parseEventBindings, triggerActive } from "./events.js";
38
40
  import { registerCoreForces } from "../forces/index.js";
39
41
  import { registerNaturalForces } from "../forces/natural.js";
40
42
  import { registerExtendedForces } from "../forces/extended.js";
@@ -87,12 +89,29 @@ export function createField(canvas, opts = {}) {
87
89
  // when unused: detection passes guard on `busHas(type)` so zero listeners cost nothing.
88
90
  const busListeners = new Map();
89
91
  const busHas = (type) => (busListeners.get(type)?.size ?? 0) > 0;
90
- function busEmit(type, payload) {
92
+ function busDeliver(type, payload) {
91
93
  const set = busListeners.get(type);
92
94
  if (set)
93
95
  for (const cb of set)
94
96
  cb(payload);
95
97
  }
98
+ // Per-frame coalescing (#684, shadow-dom §31): a force/condition can cross the same edge more than
99
+ // once within a single frame (multiple detection passes, a same-frame fill+release), which would
100
+ // otherwise fire the bus repeatedly per tick. busEmit buffers occurrences during the frame; the
101
+ // single flushBusEvents() at frame end delivers at most ONE event per (source, type) — last write
102
+ // wins. Behaviour-preserving for listeners that just react to "it happened"; it removes intra-frame
103
+ // duplicates only. detectProximityEvents/updateCaptureEvents already dedupe per *state transition*;
104
+ // this layer additionally guarantees one delivery per frame regardless of how many passes ran.
105
+ const busCoalescer = new FieldEventCoalescer();
106
+ function busEmit(type, payload) {
107
+ if (!busHas(type))
108
+ return; // no listeners → don't even buffer
109
+ busCoalescer.record(type, payload);
110
+ }
111
+ // Flush the frame's coalesced bus events to listeners — called once at the end of each frame.
112
+ function flushBusEvents() {
113
+ busCoalescer.flush(busDeliver);
114
+ }
96
115
  const sinkPeak = new WeakMap(); // matter held at the rising edge, for the release count
97
116
  // #441 body-proximity events. Object-identity membership (survives rescan; removed bodies pruned).
98
117
  const insideOf = new Map(); // enter/exit: bodies currently within a body's range
@@ -151,7 +170,22 @@ export function createField(canvas, opts = {}) {
151
170
  m.delete(k);
152
171
  }
153
172
  const programmaticBodies = []; // bodies added via addBody(); merged into `bodies` each scan
173
+ // programmatic edges (addEdge): relationships between two addBody bodies, with no DOM. Each carries a
174
+ // pure RelationshipAgent stepped every frame (strengthen-with-use / decay / memory). handleToBody lets
175
+ // addEdge resolve the two bodies from the handles addBody returned.
176
+ const handleToBody = new WeakMap();
177
+ const programmaticEdges = [];
178
+ let edgeSeq = 0;
179
+ // Reserved agent-threshold events (§22.5, FIELD_EVENTS): per-body hysteretic edge detectors that
180
+ // turn a continuous metric (sink load, density, attention, entropy) into one debounced `field:*`
181
+ // CustomEvent on its rising edge — never per-frame. Lazy: a body gets a Thresholder for a metric
182
+ // only the first frame it has a value to test, and the maps are pruned on rescan with the body.
183
+ // Keyed body → metric-name → Thresholder; edges track relationship `memory` the same way.
184
+ const bodyThresholds = new WeakMap();
185
+ const edgeThresholds = new WeakMap();
154
186
  const fieldChannels = new Map(); // addField() input channels
187
+ // custom overlay functions registered via registerOverlay(); keyed by name, dispatched from renderOverlay()
188
+ const customOverlays = new Map();
155
189
  // All 36 forces are registered on every field — there is no opt-in. Any of them activates per-body
156
190
  // through its `data-body` token (e.g. `data-body="lens crystallize"`); an unused force costs nothing.
157
191
  registerCoreForces(reg); // the canonical nine (§6)
@@ -175,8 +209,11 @@ export function createField(canvas, opts = {}) {
175
209
  density: opts.density && opts.density > 0 ? opts.density : 1,
176
210
  render: opts.render ?? 'none', // signals-first default (#538): a bare field runs the sim + feedback but draws nothing until asked. Pass render:'dots' for the particle surface.
177
211
  waves: opts.waves ?? true, // draw the background Currents (§24); opt-out for the bare field
212
+ waveStyle: opts.waveStyle ?? 'linear',
213
+ waveCenter: opts.waveCenter ?? null,
178
214
  background: opts.background ?? 'opaque', // 'transparent' → clear to transparent, underlay over light content
179
215
  mass: opts.mass ?? false, // first-class mass (§21.3): m ∝ size when on
216
+ separation: opts.separation != null && opts.separation >= 0 ? opts.separation : 0,
180
217
  attention: opts.attention ?? false, // conserved attention (§2.4), opt-in
181
218
  causality: opts.causality ?? false, // cross-boundary causality (Concept 4), opt-in
182
219
  heatmap: opts.heatmap ?? false, // density heatmap layer (field-systems H1), opt-in
@@ -229,6 +266,7 @@ export function createField(canvas, opts = {}) {
229
266
  let waves = [];
230
267
  let bound = [];
231
268
  let boundTarget = 0;
269
+ let resolvedWaveCenter = null;
232
270
  // the injected sources (#371): every random draw and wall-clock read in the engine flows
233
271
  // through these two, so a seeded rng + fixed clock make a run reproducible end to end.
234
272
  const rng = opts.rng ?? Math.random;
@@ -278,6 +316,10 @@ export function createField(canvas, opts = {}) {
278
316
  let emitters = [];
279
317
  let sparks = [];
280
318
  let eventEls = [];
319
+ // element trigger class-toggle (§22.3, FACM #687): `data-class="dense:lit, captured:full"` adds a
320
+ // class while a trigger holds and removes it when the trigger releases — the declarative, no-JS
321
+ // counterpart of the `data-on` CustomEvent binding (same trigger vocabulary, parsed identically).
322
+ let classEls = [];
281
323
  let engaged = []; // [data-hot] listeners, for teardown
282
324
  // shadow-DOM participation (docs/engine-reference/shadow-dom.md): encapsulated components dispatch composed
283
325
  // register/unregister/update events; the field registers the HOST and never inspects the
@@ -359,6 +401,12 @@ export function createField(canvas, opts = {}) {
359
401
  tearBoundNear(bound, waves, b.cx, b.cy, 320, W, H, env.t, (p) => void store.add(newParticle(p)));
360
402
  // release docks any DOM elements this sink had captured (§22.3, element capture).
361
403
  undockFrom(b);
404
+ // saturation is the threshold transition for field:saturated (§22.5, FACM #686): the sink
405
+ // reached its capacity this frame (that is what triggered the supernova). The force pass resets
406
+ // accreted to 0 immediately, so the load metric is never observable at ≈1 after the fact — the
407
+ // honest place to announce saturation is here, the moment it happens. field:released (below) is
408
+ // the paired down-edge, so a consumer gets both ends of the cycle.
409
+ fireThreshold(b.el, 'field:saturated', { peak: released.length });
362
410
  // and fires field:released on the falling edge of accreting (capture/release events, §22.5).
363
411
  if (b.el.dataset.fxCap === '1') {
364
412
  b.el.dataset.fxCap = '0';
@@ -504,6 +552,16 @@ export function createField(canvas, opts = {}) {
504
552
  bindings: parseEventBindings(el.dataset.on ?? ''),
505
553
  };
506
554
  });
555
+ // element trigger class-toggle (§22.3, FACM #687): same trigger:value grammar as data-on, but the
556
+ // value is a class name toggled on the element instead of an event name dispatched.
557
+ classEls = [...host.root.querySelectorAll('[data-class]')].map((node) => {
558
+ const el = node;
559
+ return {
560
+ el,
561
+ body: bodies.find((b) => b.el === el) ?? null,
562
+ bindings: parseEventBindings(el.dataset.class ?? ''),
563
+ };
564
+ });
507
565
  // resolve `warp` pairings (§22.3 relocate): a body's data-pair selector → the paired body, whose
508
566
  // live centre becomes the relocate target each frame (updateWarpTargets).
509
567
  for (const b of bodies) {
@@ -622,6 +680,31 @@ export function createField(canvas, opts = {}) {
622
680
  }
623
681
  }
624
682
  }
683
+ // element trigger class-toggle (§22.3, FACM #687): the declarative element-trigger consumer. While a
684
+ // trigger holds (the same dense/sparse/engaged/captured vocabulary data-on uses), add its class to
685
+ // the element; remove it when the trigger releases. Idempotent via classList + the per-binding armed
686
+ // flag, so a held trigger toggles exactly once. Opt-in by `data-class`, so it never touches existing
687
+ // content — the no-JS counterpart of doing `el.classList.toggle()` inside a data-on handler.
688
+ function updateClassToggles() {
689
+ if (classEls.length === 0)
690
+ return;
691
+ for (const ce of classEls) {
692
+ const s = ce.body
693
+ ? { d: ce.body.d, on: ce.body.on, accreted: ce.body.accreted }
694
+ : { d: 0, on: ce.el.dataset.active === '1', accreted: 0 };
695
+ for (const bind of ce.bindings) {
696
+ const active = triggerActive(bind.trigger, s);
697
+ if (active && !bind.armed) {
698
+ bind.armed = true;
699
+ ce.el.classList.add(bind.event); // `event` carries the class name for a data-class binding
700
+ }
701
+ else if (!active && bind.armed) {
702
+ bind.armed = false;
703
+ ce.el.classList.remove(bind.event);
704
+ }
705
+ }
706
+ }
707
+ }
625
708
  // dispatch a discrete field event on an element, with the forces:* alias (migration window).
626
709
  function fireCaptureEvent(el, name, detail) {
627
710
  el.dispatchEvent(new CustomEvent('field:' + name, { bubbles: true, composed: true, detail }));
@@ -652,6 +735,72 @@ export function createField(canvas, opts = {}) {
652
735
  }
653
736
  }
654
737
  }
738
+ // Reserved agent-threshold events (§22.5): turn each body's continuous metrics into debounced,
739
+ // hysteretic `field:*` CustomEvents on the element as it crosses a level. Mirrors the lit/dim
740
+ // contract (rising edge fires once, falls re-arm), but routed through the shared Thresholder so the
741
+ // enter/exit levels + debounce are uniform. Every event is paired (a "warning"/"shifted" rising
742
+ // edge and an `*-cleared` falling edge) so a consumer can react both ways. Lazy & cheap: a body
743
+ // only allocates a Thresholder for a metric the first frame that metric has a value to test, and
744
+ // the per-frame loop is a few comparisons. `field:entered`/`field:exited` here are the body's own
745
+ // density crossing (distinct from `field:lit`/`field:dim`, which carry the neighbour-spillover lit
746
+ // channel). See FIELD_EVENTS in agents/event-agent.ts and docs/canonical/agent-consumption-model.md.
747
+ // density / attention / entropy are continuous channels sampled per frame → hysteretic Thresholder.
748
+ // saturation is a discrete transition (the sink hitting capacity), so field:saturated is fired from
749
+ // the supernova callback above, not here (the load metric is reset to 0 before this loop runs).
750
+ const THRESHOLD_EVENTS = [
751
+ { metric: 'density', enter: 0.6, exit: 0.2, rise: 'field:entered', fall: 'field:exited' },
752
+ { metric: 'attention', enter: 1.5, exit: 1.1, rise: 'field:attention-shifted', fall: 'field:attention-settled' },
753
+ { metric: 'entropy', enter: 0.7, exit: 0.4, rise: 'field:entropy-warning', fall: 'field:entropy-cleared' },
754
+ ];
755
+ const THRESHOLD_DEBOUNCE_MS = 120;
756
+ function fireThreshold(el, name, detail) {
757
+ el.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
758
+ }
759
+ function dispatchBodyThreshold(store, key, el, spec, value, now) {
760
+ let metrics = store.get(key);
761
+ if (!metrics)
762
+ store.set(key, (metrics = new Map()));
763
+ let th = metrics.get(spec.metric);
764
+ if (!th)
765
+ metrics.set(spec.metric, (th = new Thresholder({ enter: spec.enter, exit: spec.exit, debounceMs: THRESHOLD_DEBOUNCE_MS })));
766
+ const edge = th.update(value, now);
767
+ if (edge === 'entered')
768
+ fireThreshold(el, spec.rise, { metric: spec.metric, value });
769
+ else if (edge === 'exited')
770
+ fireThreshold(el, spec.fall, { metric: spec.metric, value });
771
+ }
772
+ function updateThresholdEvents(now) {
773
+ for (const b of bodies) {
774
+ if (!b.vis || b.tokens.length === 0)
775
+ continue;
776
+ for (const spec of THRESHOLD_EVENTS) {
777
+ let value;
778
+ switch (spec.metric) {
779
+ case 'density':
780
+ value = b.d;
781
+ break;
782
+ case 'attention':
783
+ // conserved-attention multiplier (1 = neutral); only meaningful while attention is on.
784
+ if (cfg.attention)
785
+ value = b.attn ?? 1;
786
+ break;
787
+ case 'entropy':
788
+ value = b.metrics?.entropy;
789
+ break;
790
+ }
791
+ if (value === undefined)
792
+ continue;
793
+ dispatchBodyThreshold(bodyThresholds, b, b.el, spec, value, now);
794
+ }
795
+ }
796
+ // relationship memory (addEdge agents, §2.7): a remembered edge crosses its memory threshold.
797
+ // The event dispatches on the source body's element so an app can react to "this relationship is
798
+ // now durably remembered" / "…has faded". (For programmatic addBody bodies the element is the
799
+ // internal synthetic stub; a DOM-backed edge surfaces it on a real node.)
800
+ for (const e of programmaticEdges) {
801
+ dispatchBodyThreshold(edgeThresholds, e.agent, e.from.el, { metric: 'memory', enter: 0.6, exit: 0.3, rise: 'field:memory-threshold', fall: 'field:memory-faded' }, e.agent.memory, now);
802
+ }
803
+ }
655
804
  // release any DOM elements a sink had docked (§22.3) — restore their transform + a11y, fire
656
805
  // field:released. Called from supernova so element capture is conserved like particle capture.
657
806
  function undockFrom(b) {
@@ -903,73 +1052,165 @@ export function createField(canvas, opts = {}) {
903
1052
  function drawWaves() {
904
1053
  const time = env.t;
905
1054
  const STEP = 16;
906
- for (const w of waves) {
907
- const [cr, cg, cb] = w.color;
908
- ctx.beginPath();
909
- ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
910
- for (let x = 0; x <= W; x += STEP)
911
- ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
912
- ctx.lineTo(W, H);
913
- ctx.lineTo(0, H);
914
- ctx.closePath();
915
- const ty = w.baseFrac * H + w.offsetY - w.amp;
916
- const grad = ctx.createLinearGradient(0, ty, 0, ty + 320);
917
- grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.11 + w.depth * 0.05) * boot})`);
918
- grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
919
- ctx.fillStyle = grad;
920
- ctx.fill();
1055
+ if (cfg.waveStyle === 'circular') {
1056
+ const c = resolvedWaveCenter || { x: W / 2, y: H / 2 };
1057
+ const maxRadius = Math.min(W, H) * 0.48;
1058
+ for (const w of waves) {
1059
+ const [cr, cg, cb] = w.color;
1060
+ ctx.beginPath();
1061
+ const dTheta = 0.08;
1062
+ let first = true;
1063
+ for (let theta = 0; theta <= 2 * Math.PI + 0.01; theta += dTheta) {
1064
+ const r = waveRAt(w, theta, time, maxRadius);
1065
+ const x = c.x + Math.cos(theta) * r;
1066
+ const y = c.y + Math.sin(theta) * r;
1067
+ if (first) {
1068
+ ctx.moveTo(x, y);
1069
+ first = false;
1070
+ }
1071
+ else {
1072
+ ctx.lineTo(x, y);
1073
+ }
1074
+ }
1075
+ ctx.closePath();
1076
+ const baseR = w.baseFrac * maxRadius + w.offsetY;
1077
+ const grad = ctx.createRadialGradient(c.x, c.y, Math.max(0, baseR - w.amp), c.x, c.y, baseR + w.amp + 80);
1078
+ grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.08 + w.depth * 0.04) * boot})`);
1079
+ grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
1080
+ ctx.fillStyle = grad;
1081
+ ctx.fill();
1082
+ }
1083
+ ctx.globalCompositeOperation = 'lighter';
1084
+ for (const w of waves) {
1085
+ const [cr, cg, cb] = w.color;
1086
+ ctx.beginPath();
1087
+ const dTheta = 0.08;
1088
+ let first = true;
1089
+ for (let theta = 0; theta <= 2 * Math.PI + 0.01; theta += dTheta) {
1090
+ const r = waveRAt(w, theta, time, maxRadius);
1091
+ const x = c.x + Math.cos(theta) * r;
1092
+ const y = c.y + Math.sin(theta) * r;
1093
+ if (first) {
1094
+ ctx.moveTo(x, y);
1095
+ first = false;
1096
+ }
1097
+ else {
1098
+ ctx.lineTo(x, y);
1099
+ }
1100
+ }
1101
+ ctx.closePath();
1102
+ ctx.lineWidth = 5;
1103
+ ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
1104
+ ctx.stroke();
1105
+ ctx.lineWidth = 1.2;
1106
+ ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
1107
+ ctx.stroke();
1108
+ }
1109
+ ctx.globalCompositeOperation = 'source-over';
921
1110
  }
922
- ctx.globalCompositeOperation = 'lighter';
923
- for (const w of waves) {
924
- const [cr, cg, cb] = w.color;
925
- ctx.beginPath();
926
- ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
927
- for (let x = 0; x <= W; x += STEP)
928
- ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
929
- // glow via a wide faint underlay then a crisp line (both additive) — not
930
- // shadowBlur, which made each of the five long strokes cost several ×.
931
- ctx.lineWidth = 5;
932
- ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
933
- ctx.stroke();
934
- ctx.lineWidth = 1.2;
935
- ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
936
- ctx.stroke();
1111
+ else {
1112
+ for (const w of waves) {
1113
+ const [cr, cg, cb] = w.color;
1114
+ ctx.beginPath();
1115
+ ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
1116
+ for (let x = 0; x <= W; x += STEP)
1117
+ ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
1118
+ ctx.lineTo(W, H);
1119
+ ctx.lineTo(0, H);
1120
+ ctx.closePath();
1121
+ const ty = w.baseFrac * H + w.offsetY - w.amp;
1122
+ const grad = ctx.createLinearGradient(0, ty, 0, ty + 320);
1123
+ grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(0.11 + w.depth * 0.05) * boot})`);
1124
+ grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
1125
+ ctx.fillStyle = grad;
1126
+ ctx.fill();
1127
+ }
1128
+ ctx.globalCompositeOperation = 'lighter';
1129
+ for (const w of waves) {
1130
+ const [cr, cg, cb] = w.color;
1131
+ ctx.beginPath();
1132
+ ctx.moveTo(0, waveYat(w, 0, time, H, 1, 1, pull));
1133
+ for (let x = 0; x <= W; x += STEP)
1134
+ ctx.lineTo(x, waveYat(w, x, time, H, 1, 1, pull));
1135
+ ctx.lineWidth = 5;
1136
+ ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.05 + w.depth * 0.04) * boot})`;
1137
+ ctx.stroke();
1138
+ ctx.lineWidth = 1.2;
1139
+ ctx.strokeStyle = `rgba(${cr},${cg},${cb},${(0.3 + w.depth * 0.22) * boot})`;
1140
+ ctx.stroke();
1141
+ }
1142
+ ctx.globalCompositeOperation = 'source-over';
937
1143
  }
938
- ctx.globalCompositeOperation = 'source-over';
939
1144
  }
940
1145
  function drawBound() {
941
1146
  ctx.globalCompositeOperation = 'lighter';
942
1147
  const time = env.t;
943
1148
  let i = 0;
944
- for (const p of bound) {
945
- const w = waves[p.wi];
946
- if (!w) {
1149
+ if (cfg.waveStyle === 'circular') {
1150
+ const c = resolvedWaveCenter || { x: W / 2, y: H / 2 };
1151
+ const maxRadius = Math.min(W, H) * 0.48;
1152
+ for (const p of bound) {
1153
+ const w = waves[p.wi];
1154
+ if (!w) {
1155
+ i++;
1156
+ continue;
1157
+ }
1158
+ if (env.dt) {
1159
+ p.progress += p.speed;
1160
+ if (p.progress > 1)
1161
+ p.progress -= 1;
1162
+ else if (p.progress < 0)
1163
+ p.progress += 1;
1164
+ }
1165
+ const theta = p.progress * 2 * Math.PI;
1166
+ const r = waveRAt(w, theta, time, maxRadius) + p.phase * 32;
1167
+ const x = c.x + Math.cos(theta) * r;
1168
+ const y = c.y + Math.sin(theta) * r;
1169
+ const [cr, cg, cb] = w.color;
1170
+ const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
1171
+ if (p.glow) {
1172
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
1173
+ ctx.beginPath();
1174
+ ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
1175
+ ctx.fill();
1176
+ }
1177
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
1178
+ ctx.beginPath();
1179
+ ctx.arc(x, y, p.size, 0, 6.28318);
1180
+ ctx.fill();
947
1181
  i++;
948
- continue;
949
1182
  }
950
- if (env.dt) {
951
- p.progress += p.speed;
952
- if (p.progress > 1)
953
- p.progress -= 1;
954
- else if (p.progress < 0)
955
- p.progress += 1;
956
- }
957
- const x = p.progress * W;
958
- const y = waveYat(w, x, time, H, 1, 1, pull) + p.phase * 32;
959
- const [cr, cg, cb] = w.color;
960
- const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
961
- if (p.glow) {
962
- // additive halo instead of shadowBlur (the canvas is composited 'lighter')
963
- ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
1183
+ }
1184
+ else {
1185
+ for (const p of bound) {
1186
+ const w = waves[p.wi];
1187
+ if (!w) {
1188
+ i++;
1189
+ continue;
1190
+ }
1191
+ if (env.dt) {
1192
+ p.progress += p.speed;
1193
+ if (p.progress > 1)
1194
+ p.progress -= 1;
1195
+ else if (p.progress < 0)
1196
+ p.progress += 1;
1197
+ }
1198
+ const x = p.progress * W;
1199
+ const y = waveYat(w, x, time, H, 1, 1, pull) + p.phase * 32;
1200
+ const [cr, cg, cb] = w.color;
1201
+ const tw = p.glow ? 0.6 + 0.4 * Math.sin(time * 2.2 + i) : 0.85;
1202
+ if (p.glow) {
1203
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${0.16 * tw * boot})`;
1204
+ ctx.beginPath();
1205
+ ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
1206
+ ctx.fill();
1207
+ }
1208
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
964
1209
  ctx.beginPath();
965
- ctx.arc(x, y, p.size + 2.5, 0, 6.28318);
1210
+ ctx.arc(x, y, p.size, 0, 6.28318);
966
1211
  ctx.fill();
1212
+ i++;
967
1213
  }
968
- ctx.fillStyle = `rgba(${cr},${cg},${cb},${tw * boot})`;
969
- ctx.beginPath();
970
- ctx.arc(x, y, p.size, 0, 6.28318);
971
- ctx.fill();
972
- i++;
973
1214
  }
974
1215
  ctx.globalCompositeOperation = 'source-over';
975
1216
  }
@@ -1587,9 +1828,12 @@ export function createField(canvas, opts = {}) {
1587
1828
  // `grid` — a reference lattice whose vertices are displaced along the felt field; the page's
1588
1829
  // space itself made visible, bending where the field is strong. Reads deformation.
1589
1830
  function drawOverlayGrid(out) {
1590
- const STEP = 56;
1591
- const MAXD = 11 * cfg.gridWarp; // px displacement at the strongest sample legible, never
1592
- // chaotic at the default gridWarp=1; the multiplier exaggerates the deformation for demos.
1831
+ const STEP = 48; // roomy cells so each mass can dimple the lattice into a deep, legible bowl; the
1832
+ // CLAMP below keeps even a deep bowl foldless (denser than the field-line default still reads fine).
1833
+ const MAXD = 22 * cfg.gridWarp; // px displacement the field asks for at the strongest sample; the
1834
+ // CLAMP below is what actually keeps it legible, so gridWarp drives how fast that ceiling is reached.
1835
+ const CLAMP = STEP * 0.46; // a vertex can move at most ~half a cell, so adjacent lines bend hard but
1836
+ // can NEVER cross — the displaced grid always reads as deep curved space, never a tangle.
1593
1837
  const cols = Math.floor(W / STEP) + 2;
1594
1838
  const rows = Math.floor(H / STEP) + 2;
1595
1839
  const dx = new Float32Array(cols * rows);
@@ -1610,27 +1854,124 @@ export function createField(canvas, opts = {}) {
1610
1854
  }
1611
1855
  }
1612
1856
  }
1613
- const acc = curAccent; // #530: the cached live accent RGB (was hexToRgb(cfg.accent) a per-frame parse)
1614
- // a bolder lattice also wants a slightly heavier line so it reads as structure, not threads;
1615
- // at the faint diagnostic default (0.16) this stays width 1 byte-identical to before.
1616
- const stroke = { r: acc[0], g: acc[1], b: acc[2], alpha: cfg.gridIntensity, width: cfg.gridIntensity > 0.3 ? 1.2 : 1 };
1857
+ // Heatmap-coloured lattice: each segment is tinted by how hard the field warps it there cool
1858
+ // accent where space is flat, through warm orange, to white-hot at the strongest deflection (the
1859
+ // mass wells). Opacity rises with the deflection too, so flat space is a faint base lattice and the
1860
+ // warped regions blaze the spacetime-curvature image made literal. Consecutive same-band segments
1861
+ // are merged into one polyline, so the colouring costs a handful of extra strokes, not one per cell.
1862
+ // neon ramp: accent blue (flat space) → electric violet → neon magenta → hot neon pink (the wells).
1863
+ // curAccent (#530) is the cached accent RGB — the cool stop the warped regions heat away from.
1864
+ const HEAT = [curAccent, [120, 110, 255], [240, 70, 255], [255, 50, 130]];
1865
+ const BANDS = 8;
1866
+ const relAt = (i) => (maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0);
1867
+ const bandAt = (i) => Math.round(relAt(i) * BANDS);
1868
+ const strokeFor = (b) => {
1869
+ const rel = b / BANDS;
1870
+ const c = sampleStops(HEAT, rel);
1871
+ // cool flat space sits at ~30% of the configured intensity; the hottest cells reach it in full.
1872
+ return { r: c[0], g: c[1], b: c[2], alpha: Math.min(1, cfg.gridIntensity * (0.3 + 0.85 * rel)), width: rel > 0.55 ? 1.4 : 1 };
1873
+ };
1874
+ // Build the raw displacement vector per vertex (direction × normalized strength × throw), then box-
1875
+ // blur it once so neighbouring vertices move together — a rubber sheet dimpling, not per-vertex
1876
+ // jitter. Blurring the displacement (not the field) is what turns the knot at each mass into a smooth
1877
+ // bowl: the inward pull is averaged with its calmer neighbours, so nothing collapses to a point.
1878
+ const wx = new Float32Array(cols * rows);
1879
+ const wy = new Float32Array(cols * rows);
1880
+ for (let i = 0; i < cols * rows; i++) {
1881
+ const rel = relAt(i);
1882
+ wx[i] = dx[i] * rel * MAXD;
1883
+ wy[i] = dy[i] * rel * MAXD;
1884
+ }
1885
+ const bx = new Float32Array(cols * rows);
1886
+ const by = new Float32Array(cols * rows);
1887
+ for (let gy = 0; gy < rows; gy++) {
1888
+ for (let gx = 0; gx < cols; gx++) {
1889
+ let sx = 0, sy = 0, n = 0;
1890
+ for (let oy = -1; oy <= 1; oy++) {
1891
+ for (let ox = -1; ox <= 1; ox++) {
1892
+ const x = gx + ox, y = gy + oy;
1893
+ if (x < 0 || y < 0 || x >= cols || y >= rows)
1894
+ continue;
1895
+ const j = y * cols + x;
1896
+ sx += wx[j];
1897
+ sy += wy[j];
1898
+ n++;
1899
+ }
1900
+ }
1901
+ const i = gy * cols + gx;
1902
+ bx[i] = sx / n;
1903
+ by[i] = sy / n;
1904
+ }
1905
+ }
1906
+ const FADE = STEP * 1.5; // taper the warp to zero within ~1.5 cells of each viewport edge, so the
1907
+ // lattice always frames the screen instead of pulling away from the edges under a strong inward warp.
1617
1908
  const px = (gx, gy) => {
1618
1909
  const i = gy * cols + gx;
1619
- const rel = maxMag > 0 ? Math.sqrt(mags[i] / maxMag) : 0;
1620
- return [gx * STEP + dx[i] * rel * MAXD, gy * STEP + dy[i] * rel * MAXD];
1910
+ let ux = bx[i], uy = by[i];
1911
+ const m = Math.hypot(ux, uy);
1912
+ if (m > CLAMP) {
1913
+ ux = (ux / m) * CLAMP;
1914
+ uy = (uy / m) * CLAMP;
1915
+ } // half-cell ceiling: never fold.
1916
+ const px0 = gx * STEP, py0 = gy * STEP;
1917
+ const e = Math.min(px0, W - px0, py0, H - py0); // distance to the nearest viewport edge
1918
+ const k = e <= 0 ? 0 : e < FADE ? e / FADE : 1; // pin the boundary (k=0), ease to full warp inward
1919
+ return [px0 + ux * k, py0 + uy * k];
1621
1920
  };
1622
- const row = [];
1921
+ // Smooth the warped lines into curves: Catmull-Rom through the displaced vertices (the curve passes
1922
+ // through every original point, so the warp magnitude is preserved) — the lattice reads as bent
1923
+ // space, not faceted segments. Single-segment runs (n < 3) are already straight, returned as-is.
1924
+ const SUB = 4;
1925
+ const smooth = (p) => {
1926
+ const n = p.length >> 1;
1927
+ if (n < 3)
1928
+ return p;
1929
+ const o = [p[0], p[1]];
1930
+ for (let i = 0; i < n - 1; i++) {
1931
+ const a = (i > 0 ? i - 1 : 0) << 1;
1932
+ const b = i << 1;
1933
+ const c = (i + 1) << 1;
1934
+ const e = (i + 2 < n ? i + 2 : n - 1) << 1;
1935
+ const x0 = p[a], y0 = p[a + 1], x1 = p[b], y1 = p[b + 1], x2 = p[c], y2 = p[c + 1], x3 = p[e], y3 = p[e + 1];
1936
+ for (let s = 1; s <= SUB; s++) {
1937
+ const t = s / SUB, t2 = t * t, t3 = t2 * t;
1938
+ o.push(0.5 * (2 * x1 + (-x0 + x2) * t + (2 * x0 - 5 * x1 + 4 * x2 - x3) * t2 + (-x0 + 3 * x1 - 3 * x2 + x3) * t3), 0.5 * (2 * y1 + (-y0 + y2) * t + (2 * y0 - 5 * y1 + 4 * y2 - y3) * t2 + (-y0 + 3 * y1 - 3 * y2 + y3) * t3));
1939
+ }
1940
+ }
1941
+ return o;
1942
+ };
1943
+ // a segment is as hot as its hottest endpoint, so a line passing a well lights up across the well.
1944
+ const hSeg = (gy, gx) => Math.max(bandAt(gy * cols + gx), bandAt(gy * cols + gx + 1));
1945
+ const vSeg = (gx, gy) => Math.max(bandAt(gy * cols + gx), bandAt((gy + 1) * cols + gx));
1623
1946
  for (let gy = 0; gy < rows; gy++) {
1624
- row.length = 0;
1625
- for (let gx = 0; gx < cols; gx++)
1626
- row.push(...px(gx, gy));
1627
- out.polyline(row, stroke);
1947
+ let band = hSeg(gy, 0);
1948
+ let pts = [...px(0, gy), ...px(1, gy)];
1949
+ for (let gx = 1; gx < cols - 1; gx++) {
1950
+ const sb = hSeg(gy, gx);
1951
+ if (sb === band)
1952
+ pts.push(...px(gx + 1, gy));
1953
+ else {
1954
+ out.polyline(smooth(pts), strokeFor(band));
1955
+ pts = [...px(gx, gy), ...px(gx + 1, gy)];
1956
+ band = sb;
1957
+ }
1958
+ }
1959
+ out.polyline(smooth(pts), strokeFor(band));
1628
1960
  }
1629
1961
  for (let gx = 0; gx < cols; gx++) {
1630
- row.length = 0;
1631
- for (let gy = 0; gy < rows; gy++)
1632
- row.push(...px(gx, gy));
1633
- out.polyline(row, stroke);
1962
+ let band = vSeg(gx, 0);
1963
+ let pts = [...px(gx, 0), ...px(gx, 1)];
1964
+ for (let gy = 1; gy < rows - 1; gy++) {
1965
+ const sb = vSeg(gx, gy);
1966
+ if (sb === band)
1967
+ pts.push(...px(gx, gy + 1));
1968
+ else {
1969
+ out.polyline(smooth(pts), strokeFor(band));
1970
+ pts = [...px(gx, gy), ...px(gx, gy + 1)];
1971
+ band = sb;
1972
+ }
1973
+ }
1974
+ out.polyline(smooth(pts), strokeFor(band));
1634
1975
  }
1635
1976
  }
1636
1977
  // shared scalar-contour pass for `temperature` and `energy` — splat a per-particle scalar onto a
@@ -1772,6 +2113,10 @@ export function createField(canvas, opts = {}) {
1772
2113
  drawOverlayPaths(out);
1773
2114
  else if (mode === 'data')
1774
2115
  drawOverlayData(out);
2116
+ // custom overlays registered via registerOverlay()
2117
+ const customFn = customOverlays.get(mode);
2118
+ if (customFn)
2119
+ customFn(out, env, W, H);
1775
2120
  }
1776
2121
  }
1777
2122
  function frame(now) {
@@ -1826,6 +2171,14 @@ export function createField(canvas, opts = {}) {
1826
2171
  // edge of engagement — the same conserved supernova ritual as saturation.
1827
2172
  dischargeDisengaged(bodies, env.supernova);
1828
2173
  }
2174
+ // Step programmatic edges (addEdge): a relationship is "active" while its source body is salient
2175
+ // (gathering matter, d > 0.08) — it then strengthens + accumulates memory, and decays while idle.
2176
+ // env.dt is frame-normalized (≈1 at 60fps); the dynamics rates are per-second, so convert (÷60).
2177
+ if (programmaticEdges.length && env.dt) {
2178
+ const dtS = env.dt / 60;
2179
+ for (const e of programmaticEdges)
2180
+ updateRelationship(e.agent, e.from.d > 0.08, 0, dtS);
2181
+ }
1829
2182
  // spine: ease the wave-bend toward the flow focus (if set) or the engaged element (§24). A live
1830
2183
  // flow focus (field.flowTo) takes priority, so the streamline spine curves to the moving target.
1831
2184
  let engaged = null;
@@ -1869,8 +2222,25 @@ export function createField(canvas, opts = {}) {
1869
2222
  p.vy += b.y;
1870
2223
  }
1871
2224
  }
2225
+ if (cfg.waveStyle === 'circular') {
2226
+ if (cfg.waveCenter) {
2227
+ resolvedWaveCenter = typeof cfg.waveCenter === 'function' ? cfg.waveCenter() : cfg.waveCenter;
2228
+ }
2229
+ else {
2230
+ const star = bodies.find((b) => b.tokens.includes('star') || b.tokens.includes('vortex'));
2231
+ if (star) {
2232
+ resolvedWaveCenter = { x: star.cx, y: star.cy };
2233
+ }
2234
+ else {
2235
+ resolvedWaveCenter = { x: W / 2, y: H / 2 };
2236
+ }
2237
+ }
2238
+ }
2239
+ else {
2240
+ resolvedWaveCenter = null;
2241
+ }
1872
2242
  updateWarpTargets(); // refresh warp relocate targets from paired bodies (§22.3) before the step
1873
- step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves });
2243
+ step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves, waveStyle: cfg.waveStyle, waveCenter: resolvedWaveCenter, separation: cfg.separation });
1874
2244
  // hover-focus (field.focusAt): hold the focused particle still and light it up — the dwell
1875
2245
  // affordance ("it stops and does something") before a click opens its record.
1876
2246
  if (focusP) {
@@ -1893,7 +2263,10 @@ export function createField(canvas, opts = {}) {
1893
2263
  writeFeedback();
1894
2264
  applyCausality();
1895
2265
  updateEvents();
2266
+ updateClassToggles(); // element trigger class-toggle (§22.3, FACM #687): toggle data-class on crossings
1896
2267
  updateCaptureEvents();
2268
+ updateThresholdEvents(now); // reserved agent-threshold events (§22.5): debounced field:* on crossings
2269
+ flushBusEvents(); // #684: deliver the frame's coalesced discrete events — one per (source, type)
1897
2270
  // Draw only when there is a surface to draw to AND the canvas can be seen. Under the
1898
2271
  // signals-only mode (`render: 'none'`, §13.7 / #297) the engine never draws — neither the
1899
2272
  // underlay nor the overlay — and `ctx` may not even exist. Under reduced motion the scene is
@@ -1990,6 +2363,15 @@ export function createField(canvas, opts = {}) {
1990
2363
  }
1991
2364
  },
1992
2365
  setFormation,
2366
+ setWaveStyle: (style) => {
2367
+ cfg.waveStyle = style;
2368
+ },
2369
+ setWaveCenter: (center) => {
2370
+ cfg.waveCenter = center;
2371
+ },
2372
+ setSeparation: (strength) => {
2373
+ cfg.separation = strength >= 0 ? strength : 0;
2374
+ },
1993
2375
  setAttention: (on) => {
1994
2376
  cfg.attention = on;
1995
2377
  if (!on)
@@ -2171,6 +2553,34 @@ export function createField(canvas, opts = {}) {
2171
2553
  }
2172
2554
  return w;
2173
2555
  },
2556
+ readParticleChannels: (channels, out) => {
2557
+ const ps = store.particles;
2558
+ let written = 0;
2559
+ for (let i = 0; i < ps.length; i++) {
2560
+ const p = ps[i];
2561
+ if ('report' in p && p.report !== undefined)
2562
+ continue;
2563
+ if (written >= Math.min(...out.map(b => b.length)))
2564
+ break;
2565
+ for (let ci = 0; ci < channels.length; ci++) {
2566
+ if (ci >= out.length)
2567
+ break;
2568
+ const ch = channels[ci];
2569
+ out[ci][written] =
2570
+ ch === 'x' ? p.x : ch === 'y' ? p.y : ch === 'z' ? (p.z ?? 0) :
2571
+ ch === 'vx' ? p.vx : ch === 'vy' ? p.vy :
2572
+ ch === 'heat' ? p.heat : ch === 'size' ? p.size :
2573
+ ch === 'm' ? p.m : ch === 'id' ? (p.id ?? 0) :
2574
+ ch === 'age' ? (p.age ?? 0) : ch === 'charge' ? (p.charge ?? 0) : 0;
2575
+ }
2576
+ written++;
2577
+ }
2578
+ return written;
2579
+ },
2580
+ registerOverlay: (name, drawFn) => {
2581
+ customOverlays.set(name, drawFn);
2582
+ return () => { customOverlays.delete(name); };
2583
+ },
2174
2584
  addAgent: (spec) => {
2175
2585
  const p = newParticle({ x: spec.x, y: spec.y, z: spec.z, species: spec.species });
2176
2586
  p.vx = 0;
@@ -2231,7 +2641,7 @@ export function createField(canvas, opts = {}) {
2231
2641
  programmaticBodies.push(body);
2232
2642
  bodies = bodies.concat(body); // live this frame, before the next scan re-merges it
2233
2643
  measureBodies([body], W, H, originX, originY); // init cx/cy so the first sample/force is correct
2234
- return {
2644
+ const handle = {
2235
2645
  data: spec.data,
2236
2646
  get channels() { return channels; },
2237
2647
  set: (params) => {
@@ -2257,9 +2667,58 @@ export function createField(canvas, opts = {}) {
2257
2667
  if (i >= 0)
2258
2668
  programmaticBodies.splice(i, 1);
2259
2669
  bodies = bodies.filter((x) => x !== body);
2670
+ // drop any programmatic edges that touched this body (#601).
2671
+ for (let k = programmaticEdges.length - 1; k >= 0; k--) {
2672
+ const pe = programmaticEdges[k];
2673
+ if (pe.from === body || pe.to === body)
2674
+ programmaticEdges.splice(k, 1);
2675
+ }
2676
+ },
2677
+ };
2678
+ handleToBody.set(handle, body);
2679
+ return handle;
2680
+ },
2681
+ addEdge: (a, b, opts) => {
2682
+ // a relationship between two programmatic bodies (the addBody handles). No DOM — the edge keys on
2683
+ // the body identities; it feeds the same pure RelationshipAgent dynamics the DOM graph uses.
2684
+ const from = handleToBody.get(a);
2685
+ const to = handleToBody.get(b);
2686
+ if (!from || !to)
2687
+ throw new Error('addEdge: both arguments must be handles returned by addBody on this field.');
2688
+ const agent = {
2689
+ id: `e${edgeSeq++}`,
2690
+ from: '', // body ids are internal; readEdges exposes the carried data instead
2691
+ to: '',
2692
+ type: opts?.type ?? 'related',
2693
+ strength: opts?.strength ?? 0.5,
2694
+ tension: 0,
2695
+ memory: 0,
2696
+ active: false,
2697
+ };
2698
+ const edge = { agent, from, to, fromData: a.data, toData: b.data };
2699
+ programmaticEdges.push(edge);
2700
+ return {
2701
+ set: (params) => {
2702
+ if (params.strength != null)
2703
+ agent.strength = params.strength < 0 ? 0 : params.strength > 1 ? 1 : params.strength;
2704
+ if (params.type != null)
2705
+ agent.type = params.type;
2706
+ },
2707
+ remove: () => {
2708
+ const i = programmaticEdges.indexOf(edge);
2709
+ if (i >= 0)
2710
+ programmaticEdges.splice(i, 1);
2260
2711
  },
2261
2712
  };
2262
2713
  },
2714
+ readEdges: () => programmaticEdges.map((e) => ({
2715
+ from: e.fromData,
2716
+ to: e.toData,
2717
+ type: e.agent.type,
2718
+ strength: e.agent.strength,
2719
+ memory: e.agent.memory,
2720
+ active: e.agent.active,
2721
+ })),
2263
2722
  addField: (name, sampler) => {
2264
2723
  fieldChannels.set(name, sampler);
2265
2724
  return {