svelte-realtime 0.6.0-next.3 → 0.6.0-next.4

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.
package/README.md CHANGED
@@ -2920,6 +2920,7 @@ export const board = live.multiplayer({
2920
2920
  - `me` - the local user's key, or `null` until you name it.
2921
2921
  - `status` - the connection-status passthrough.
2922
2922
  - `move(...)` / `reportViewport(...)` - forward to the cursor send path.
2923
+ - `typing` / `selections` / `locks` / `reactions` and their send methods, when the matching field surfaces are declared (see [Field surfaces](#field-surfaces-typing-selections-locks-reactions)).
2923
2924
 
2924
2925
  Name the current user once with `board.identify(key)` - the same identity you already supply for presence, available from the SvelteKit page load. Calling `identify(key)` after `board.room(...)` still lights up `me` and self-exclusion. If you never call it the surface degrades gracefully: `others` is the full deduped roster and `me` reads `null`, never a crash.
2925
2926
 
@@ -2978,9 +2979,50 @@ The raw `data` / `presence` / `cursors` factory stores stay on the export for ba
2978
2979
  {/each}
2979
2980
  ```
2980
2981
 
2981
- ### Reserved surfaces
2982
+ ### Field surfaces: typing, selections, locks, reactions
2982
2983
 
2983
- The `typing`, `locks`, `selections`, and `reactions` views and their methods (`setTyping`, `acquireLock`, `releaseLock`, `setSelection`, `react`) are present on the export so the API shape is stable, but they are inert for now: the views read empty and the methods are no-ops that emit a single dev-mode note. They activate once the client-to-server field send path lands; declaring `typing: true`, `locks: ['cell']`, `reactions: true`, or `selections: 'offset'` reserves the surface without changing today's behavior.
2984
+ Declare a collaborative field surface on the export and the room view lights up a reactive projection plus a send method for it. Typing, selections, and locks are published onto the same presence roster `room.others` reads, so they cost no extra subscription; reactions ride a dedicated stream.
2985
+
2986
+ ```js
2987
+ export const board = live.multiplayer({
2988
+ topic: (ctx, boardId) => 'board:' + boardId,
2989
+ topicArgs: 1,
2990
+ init: async (ctx, boardId) => db.cards.forBoard(boardId),
2991
+ presence: (ctx) => ({ name: ctx.user.name }),
2992
+ cursors: true,
2993
+ typing: true,
2994
+ selections: 'offset',
2995
+ locks: ['title', 'body'],
2996
+ reactions: true
2997
+ });
2998
+ ```
2999
+
3000
+ - **Typing** - `room.typing` is the list of remote collaborators' user keys currently flagged as typing (self excluded). Toggle the local flag with `room.setTyping(true)` / `room.setTyping(false)`. Typing is a transient flag and is never persisted on the roster.
3001
+ - **Selections** - `room.selections` is a `{ userKey: range }` map of remote selection ranges (self excluded). Publish the local offset-mode range with `room.setSelection({ start, end, nodePath })`; pass `null` to clear it. A selection is stamped on the caller's roster entry, so a late joiner loads the current selections from the roster snapshot instead of waiting for the next change.
3002
+ - **Locks** - `room.locks` is a `{ lockKey: holderUserKey }` map. `room.acquireLock('title')` announces an advisory claim on a key and `room.releaseLock('title')` clears it. A holder disconnecting drops its claims on the next roster push. These are soft, collaborative-awareness locks (they tell collaborators who is editing what), not distributed mutual exclusion - a second caller is not blocked. A held lock is stamped on the caller's roster entry, so a late joiner sees who holds what from the roster snapshot.
3003
+ - **Reactions** - `room.reactions` is a bounded ring of recent ephemeral emotes; `room.react('heart', { x, y })` emits one. Reactions are never coalesced, so a burst of taps all arrive, and old entries fall off the ring.
3004
+
3005
+ Selections and locks persist on the roster both single-instance and across a cluster (wire `platform.redis`); typing stays ephemeral. Because a field is stamped on a roster entry that only exists once presence is set, declaring `typing`, `selections`, or `locks` requires a `presence` function - the Vite plugin and `live.multiplayer()` both reject a presence field with no presence. `reactions` are exempt: they ride their own ephemeral stream and need no presence.
3006
+
3007
+ ```svelte
3008
+ <script>
3009
+ import { board } from '$live/collab';
3010
+ let { boardId } = $props();
3011
+ const room = board.room(boardId);
3012
+ </script>
3013
+
3014
+ <input
3015
+ oninput={() => room.setTyping(true)}
3016
+ onblur={() => room.setTyping(false)} />
3017
+
3018
+ {#if room.typing.length}
3019
+ <p>{room.typing.length} editing...</p>
3020
+ {/if}
3021
+
3022
+ <button onclick={() => room.react('heart', { x: 100, y: 40 })}>React</button>
3023
+ ```
3024
+
3025
+ A multiplayer export that declares no field surface is unchanged, and calling a field method on the namespace (rather than a `room(...)` instance) is a safe no-op that points you at `room(...)`.
2984
3026
 
2985
3027
  ### Deterministic user colors
2986
3028
 
@@ -3839,7 +3881,7 @@ Import from `svelte-realtime/server`.
3839
3881
  | `live.effect(sources, fn, options?)` | Server-side reactive side effect |
3840
3882
  | `live.aggregate(source, reducers, options)` | Real-time incremental aggregation |
3841
3883
  | `live.room(config)` | Collaborative room (data + presence + cursors + actions) |
3842
- | `live.multiplayer(config)` | Collaborative surface: room sub-streams plus an aggregated roster view (`room(...)` -> `others` / `cursors` / `me`, colored and deduped), live cursors (move / reportViewport), connection status, and `identify(key)` |
3884
+ | `live.multiplayer(config)` | Collaborative surface: room sub-streams plus an aggregated roster view (`room(...)` -> `others` / `cursors` / `me`, colored and deduped), live cursors (move / reportViewport), field surfaces (typing / selections / advisory locks / reactions), connection status, and `identify(key)` |
3843
3885
  | `live.webhook(topic, config)` | HTTP webhook-to-stream bridge |
3844
3886
  | `live.gate(predicate, fn)` | Conditional stream activation |
3845
3887
  | `live.rateLimit(config, fn)` | Per-function sliding window rate limiter |
@@ -40,10 +40,22 @@ export interface MultiplayerRoomDeps {
40
40
  cursors: Readable<any[]>;
41
41
  /** Connection-status store. */
42
42
  status: Readable<string>;
43
+ /** Reactions sub-stream (the bounded ring of recent emotes), when enabled. */
44
+ reactions?: Readable<any[]>;
43
45
  /** Outbound cursor-move send callback. */
44
46
  move?: (...args: any[]) => any;
45
47
  /** Outbound viewport-report send callback. Falls back to `move`. */
46
48
  reportViewport?: (...args: any[]) => any;
49
+ /** Outbound presence-field send callback for typing toggles. */
50
+ setTyping?: (...args: any[]) => any;
51
+ /** Outbound presence-field send callback for selection ranges. */
52
+ setSelection?: (...args: any[]) => any;
53
+ /** Outbound presence-field send callback for advisory lock acquisition. */
54
+ acquireLock?: (...args: any[]) => any;
55
+ /** Outbound presence-field send callback for advisory lock release. */
56
+ releaseLock?: (...args: any[]) => any;
57
+ /** Outbound reaction send callback. */
58
+ react?: (...args: any[]) => any;
47
59
  }
48
60
 
49
61
  /**
@@ -60,8 +72,9 @@ export interface MultiplayerRoomDeps {
60
72
  * - `me`: the local user's key, or `null` when the app never supplied one.
61
73
  * - `status`: the connection-status passthrough.
62
74
  *
63
- * The `typing` / `locks` / `selections` / `reactions` field surfaces and their
64
- * methods are reserved and inert.
75
+ * The `typing` / `locks` / `selections` views are reactive projections of the
76
+ * presence roster, driven by the field-send methods; `reactions` is the bounded
77
+ * ring of recent ephemeral emotes.
65
78
  */
66
79
  export class MultiplayerRoom {
67
80
  constructor(deps: MultiplayerRoomDeps);
@@ -73,28 +86,28 @@ export class MultiplayerRoom {
73
86
  get me(): string | null;
74
87
  /** The connection status. */
75
88
  get status(): string;
76
- /** Reserved field surface. Inert. */
77
- get typing(): any[];
78
- /** Reserved field surface. Inert. */
89
+ /** The user keys of remote collaborators currently flagged as typing. */
90
+ get typing(): string[];
91
+ /** Advisory lock holders keyed by lock key: `{ lockKey: holderUserKey }`. */
79
92
  get locks(): Record<string, any>;
80
- /** Reserved field surface. Inert. */
93
+ /** Remote selection ranges keyed by user (self excluded). */
81
94
  get selections(): Record<string, any>;
82
- /** Reserved field surface. Inert. */
95
+ /** The bounded ring of recent reactions. */
83
96
  get reactions(): any[];
84
97
  /** Forward a cursor move to the injected send callback. */
85
98
  move(...args: any[]): any;
86
99
  /** Forward a viewport report to the injected send callback. */
87
100
  reportViewport(...args: any[]): any;
88
- /** Reserved no-op. */
89
- react(...args: any[]): void;
90
- /** Reserved no-op. */
91
- setTyping(...args: any[]): void;
92
- /** Reserved no-op. */
93
- acquireLock(...args: any[]): void;
94
- /** Reserved no-op. */
95
- releaseLock(...args: any[]): void;
96
- /** Reserved no-op. */
97
- setSelection(...args: any[]): void;
101
+ /** Emit an ephemeral reaction (a token at an optional point). */
102
+ react(token: any, at?: any): any;
103
+ /** Toggle the local typing flag, published onto the presence roster. */
104
+ setTyping(on: boolean): any;
105
+ /** Claim an advisory lock on a key (collaborative awareness, not exclusion). */
106
+ acquireLock(lockKey: string): any;
107
+ /** Release an advisory lock on a key. */
108
+ releaseLock(lockKey: string): any;
109
+ /** Publish the local selection range; pass `null` to clear it. */
110
+ setSelection(selection: any): any;
98
111
  /** Unsubscribe from the injected stores. Call when the room unmounts. */
99
112
  destroy(): void;
100
113
  }
@@ -50,6 +50,12 @@ function dedupeByUser(list) {
50
50
  * means `identify(key)` called after the room is constructed updates self-
51
51
  * exclusion live.
52
52
  *
53
+ * The collaborative field surfaces - `typing`, `selections`, `locks` - are
54
+ * projections of the same presence roster: a caller stamps fields onto its own
55
+ * entry through the injected send callbacks, the presence merge layers them in,
56
+ * and these views read them back. `reactions` is the bounded ring of recent
57
+ * ephemeral emotes from the dedicated reactions stream.
58
+ *
53
59
  * The views are `$derived` over `$state` snapshots of the injected stores, so a
54
60
  * store push followed by a reactive flush refreshes every view.
55
61
  */
@@ -57,18 +63,32 @@ export class MultiplayerRoom {
57
63
  #meSource;
58
64
  #presence = $state([]);
59
65
  #cursors = $state([]);
66
+ #reactions = $state([]);
60
67
  #status = $state('idle');
61
68
  #move;
62
69
  #reportViewport;
70
+ #setTyping;
71
+ #setSelection;
72
+ #acquireLock;
73
+ #releaseLock;
74
+ #react;
63
75
  #unsubs = [];
64
76
 
65
77
  constructor(deps) {
66
78
  this.#meSource = deps.me;
67
79
  this.#move = deps.move;
68
80
  this.#reportViewport = deps.reportViewport || deps.move;
81
+ this.#setTyping = deps.setTyping;
82
+ this.#setSelection = deps.setSelection;
83
+ this.#acquireLock = deps.acquireLock;
84
+ this.#releaseLock = deps.releaseLock;
85
+ this.#react = deps.react;
69
86
  this.#unsubs.push(deps.presence.subscribe((v) => { this.#presence = v || []; }));
70
87
  this.#unsubs.push(deps.cursors.subscribe((v) => { this.#cursors = v || []; }));
71
88
  this.#unsubs.push(deps.status.subscribe((v) => { this.#status = v; }));
89
+ if (deps.reactions) {
90
+ this.#unsubs.push(deps.reactions.subscribe((v) => { this.#reactions = v || []; }));
91
+ }
72
92
  }
73
93
 
74
94
  // Reads the reactive holder when one was injected (so identify(key) after
@@ -95,21 +115,91 @@ export class MultiplayerRoom {
95
115
  );
96
116
  get cursors() { return this.#cursorsDerived; }
97
117
 
98
- // Stubbed field surfaces: present so the API shape is stable, inert until
99
- // the client-to-server presence-field send path lands.
100
- get typing() { return []; }
101
- get locks() { return {}; }
102
- get selections() { return {}; }
103
- get reactions() { return []; }
118
+ // Field surfaces are projections of the same presence roster `others` reads:
119
+ // a caller stamps fields onto its own roster entry through the send path, the
120
+ // presence merge layers them onto the entry, and these views read them back.
121
+ // They add no new store subscription - one derive pass over the roster the
122
+ // room already aggregates.
123
+
124
+ // The keys of remote collaborators currently flagged as typing.
125
+ #typingDerived = $derived(
126
+ dedupeByUser(this.#presence)
127
+ .filter((p) => p.typing === true && (this.me == null || p.key !== this.me))
128
+ .map((p) => p.key)
129
+ );
130
+ get typing() { return this.#typingDerived; }
131
+
132
+ // Advisory lock holders, keyed by lock key. A roster entry carries a held key
133
+ // as `lock:<key>` (truthy while held, cleared on release); the holder is the
134
+ // entry's own user key. This collapses the roster into a { lockKey: holder }
135
+ // map. A holder leaving drops its entry, so its locks recompute to absent on
136
+ // the next push - no stale grant survives.
137
+ #locksDerived = $derived(this.#deriveLocks(this.#presence));
138
+ get locks() { return this.#locksDerived; }
139
+
140
+ // Remote selections, keyed by user. Self is excluded so an app renders only
141
+ // collaborators' ranges. Each value is the selection payload the holder sent.
142
+ #selectionsDerived = $derived(
143
+ Object.fromEntries(
144
+ dedupeByUser(this.#presence)
145
+ .filter((p) => p.selection != null && (this.me == null || p.key !== this.me))
146
+ .map((p) => [p.key, p.selection])
147
+ )
148
+ );
149
+ get selections() { return this.#selectionsDerived; }
150
+
151
+ // The bounded ring of recent reactions. The send stream caps and GCs old
152
+ // taps, so an app renders the current window and lets entries fall off.
153
+ get reactions() { return this.#reactions; }
154
+
155
+ /** @param {Array<Record<string, any>>} roster */
156
+ #deriveLocks(roster) {
157
+ const out = /** @type {Record<string, any>} */ ({});
158
+ for (const entry of dedupeByUser(roster)) {
159
+ for (const field in entry) {
160
+ if (field.startsWith('lock:') && entry[field] != null && entry[field] !== false) {
161
+ out[field.slice(5)] = entry.key;
162
+ }
163
+ }
164
+ }
165
+ return out;
166
+ }
104
167
 
105
168
  move(...args) { return this.#move ? this.#move(...args) : undefined; }
106
169
  reportViewport(...args) { return this.#reportViewport ? this.#reportViewport(...args) : undefined; }
107
170
 
108
- react() {}
109
- setTyping() {}
110
- acquireLock() {}
111
- releaseLock() {}
112
- setSelection() {}
171
+ // Toggle the local typing flag. Publishes a `{ typing }` delta onto the
172
+ // caller's presence entry so every collaborator's `typing` view updates.
173
+ setTyping(on) {
174
+ return this.#setTyping ? this.#setTyping({ typing: !!on }) : undefined;
175
+ }
176
+
177
+ // Publish the local selection range. `null` clears it. Offset selections are
178
+ // a plain `{ start, end, nodePath }` object; the value is sent verbatim.
179
+ setSelection(selection) {
180
+ return this.#setSelection ? this.#setSelection({ selection: selection ?? null }) : undefined;
181
+ }
182
+
183
+ // Claim an advisory lock on a key: stamps `lock:<key>` on the caller's
184
+ // presence entry, which the server keys by the caller's identity. Advisory
185
+ // only - it announces intent, it does not block another claimant.
186
+ acquireLock(lockKey) {
187
+ if (!this.#acquireLock || lockKey == null) return undefined;
188
+ return this.#acquireLock({ ['lock:' + lockKey]: true });
189
+ }
190
+
191
+ // Release an advisory lock: clears `lock:<key>` on the caller's entry.
192
+ releaseLock(lockKey) {
193
+ if (!this.#releaseLock || lockKey == null) return undefined;
194
+ return this.#releaseLock({ ['lock:' + lockKey]: null });
195
+ }
196
+
197
+ // Emit an ephemeral reaction (an emote token at an optional point). Rides the
198
+ // dedicated reactions stream, never the roster, so it is a one-off event.
199
+ react(token, at) {
200
+ if (!this.#react) return undefined;
201
+ return at !== undefined ? this.#react(token, at) : this.#react(token);
202
+ }
113
203
 
114
204
  destroy() {
115
205
  for (const off of this.#unsubs) off();
@@ -0,0 +1,138 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Injectable runtime environment for the clock, RNG, and timers - browser build.
5
+ *
6
+ * Browser client code reads time, randomness, and schedules timers exclusively
7
+ * through the named helpers exported here instead of touching `Date.now`,
8
+ * `Math.random`, `crypto`, or `setTimeout` directly. The helper names and the
9
+ * environment shape are kept byte-identical with the node `shared/runtime.js`
10
+ * copy so the two never drift and callers are interchangeable. The difference is
11
+ * the binding: this copy is backed entirely by browser globals (no `node:`
12
+ * imports), so it bundles cleanly for the browser, while a controlled simulation
13
+ * harness running the same client code under node can still swap in seeded
14
+ * virtual implementations via `setRuntimeEnv`.
15
+ *
16
+ * In production the helpers bind the native globals with zero measurable
17
+ * overhead: one read over a frozen, never-swapped environment object that V8
18
+ * keeps monomorphic and inlines straight through to the native calls.
19
+ *
20
+ * @module svelte-realtime/client-runtime
21
+ */
22
+
23
+ // The browser clock is a direct wall read: the clients never had a 1Hz cache,
24
+ // so reading `Date.now()` per call preserves their existing behavior (and lets a
25
+ // test's fake-timer Date mock propagate directly). `performance.now()` drives
26
+ // monotonic duration math when present, falling back to the wall clock when not.
27
+ const _hasPerf = typeof globalThis.performance !== 'undefined' && typeof globalThis.performance.now === 'function';
28
+ const _processStartEpoch = _hasPerf ? Date.now() - globalThis.performance.now() : 0;
29
+ const _webcrypto = (typeof globalThis.crypto !== 'undefined') ? globalThis.crypto : undefined;
30
+
31
+ // v4-shaped fallback when Web Crypto randomUUID is unavailable (non-secure
32
+ // context / old browser). Uses the env RNG so a seeded run still reproduces it.
33
+ function _uuidFallback(rngFloat) {
34
+ let out = '';
35
+ for (let i = 0; i < 36; i++) {
36
+ if (i === 8 || i === 13 || i === 18 || i === 23) { out += '-'; continue; }
37
+ if (i === 14) { out += '4'; continue; }
38
+ const r = (rngFloat() * 16) | 0;
39
+ out += (i === 19 ? ((r & 0x3) | 0x8) : r).toString(16);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ // One frozen environment object, one stable hidden class. In production
45
+ // `current === defaultEnv` for the whole page lifetime (no override ever
46
+ // installs), so V8 sees a monomorphic shape and inlines the helpers to the
47
+ // native primitives - zero measurable overhead on the hot path.
48
+ const defaultEnv = Object.freeze({
49
+ clock: Object.freeze({
50
+ now: () => Date.now(), // wall, direct read
51
+ monotonic: _hasPerf ? () => _processStartEpoch + globalThis.performance.now() : () => Date.now(), // strictly-forward duration math
52
+ wallEpoch: () => Date.now() // exact wall clock; identity baseline
53
+ }),
54
+ rng: Object.freeze({
55
+ float: () => Math.random(),
56
+ u32: () => (Math.random() * 0x100000000) >>> 0,
57
+ uuid: (_webcrypto && typeof _webcrypto.randomUUID === 'function')
58
+ ? () => _webcrypto.randomUUID()
59
+ : () => _uuidFallback(() => Math.random()),
60
+ bytes: (_webcrypto && typeof _webcrypto.getRandomValues === 'function')
61
+ ? (n) => _webcrypto.getRandomValues(new Uint8Array(n))
62
+ : (n) => { const a = new Uint8Array(n); for (let i = 0; i < n; i++) a[i] = (Math.random() * 256) | 0; return a; }
63
+ }),
64
+ timers: Object.freeze({
65
+ set: (cb, ms, ...a) => setTimeout(cb, ms, ...a),
66
+ setInterval: (cb, ms, ...a) => setInterval(cb, ms, ...a),
67
+ // No setImmediate in the browser: a zero-delay macrotask is the closest.
68
+ setImmediate: (cb, ...a) => setTimeout(cb, 0, ...a),
69
+ clear: (h) => clearTimeout(h),
70
+ clearInterval: (h) => clearInterval(h),
71
+ queueMicrotask: (typeof globalThis.queueMicrotask === 'function')
72
+ ? (cb) => globalThis.queueMicrotask(cb)
73
+ : (cb) => Promise.resolve().then(cb)
74
+ }),
75
+ tz: undefined // effective timezone for cron evaluation; undefined = real local TZ
76
+ });
77
+
78
+ let current = defaultEnv;
79
+
80
+ // The named helpers are the ONLY thing browser client code imports. Each is a
81
+ // one-line read over `current` - monomorphic in prod, inlined by V8.
82
+ export const now = () => current.clock.now();
83
+ export const monotonicNow = () => current.clock.monotonic();
84
+ export const wallEpoch = () => current.clock.wallEpoch();
85
+ export const randomFloat = () => current.rng.float();
86
+ export const randomU32 = () => current.rng.u32();
87
+ export const randomUuid = () => current.rng.uuid();
88
+ export const randomBytes = (n) => current.rng.bytes(n);
89
+ export const setTimer = (cb, ms, ...a) => current.timers.set(cb, ms, ...a);
90
+ export const setIntervalTimer = (cb, ms, ...a) => current.timers.setInterval(cb, ms, ...a);
91
+ export const setImmediateTimer = (cb, ...a) => current.timers.setImmediate(cb, ...a);
92
+ export const clearTimer = (h) => current.timers.clear(h);
93
+ export const clearIntervalTimer = (h) => current.timers.clearInterval(h);
94
+ export const microtask = (cb) => current.timers.queueMicrotask(cb);
95
+ export const effectiveTimeZone = () => current.tz;
96
+
97
+ /**
98
+ * Install a virtual environment (the simulator / test harness only). Refuses in
99
+ * a node production build unless explicitly forced, so a stray call can never
100
+ * swap the clock under a live deployment; in a real browser there is no
101
+ * `process` and nothing calls this anyway. A partial env merges over the native
102
+ * defaults, so a harness can override just the clock and keep native rng / timers.
103
+ *
104
+ * @param {object} [env] - Partial environment. Any of `clock`, `rng`, `timers`
105
+ * may carry a subset of their fields; provided fields override the native
106
+ * default, omitted fields fall through to native. `tz` overrides only when the
107
+ * property is present (including an explicit `undefined`).
108
+ * @param {object} [opts]
109
+ * @param {boolean} [opts.force] - Allow the swap even when
110
+ * `process.env.NODE_ENV === 'production'`. Use only inside a controlled
111
+ * simulation harness.
112
+ * @returns {object} The newly installed frozen environment.
113
+ */
114
+ export function setRuntimeEnv(env, opts) {
115
+ const force = opts && opts.force === true;
116
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production' && !force) {
117
+ throw new Error('client runtime: setRuntimeEnv refused in production (pass { force: true } only inside a controlled simulation harness)');
118
+ }
119
+ current = Object.freeze({
120
+ clock: Object.freeze({ ...defaultEnv.clock, ...(env && env.clock) }),
121
+ rng: Object.freeze({ ...defaultEnv.rng, ...(env && env.rng) }),
122
+ timers: Object.freeze({ ...defaultEnv.timers, ...(env && env.timers) }),
123
+ tz: env && Object.prototype.hasOwnProperty.call(env, 'tz') ? env.tz : defaultEnv.tz
124
+ });
125
+ return current;
126
+ }
127
+
128
+ /**
129
+ * Restore the native environment. Cheap wholesale reassignment (no per-field
130
+ * mutation), so the hidden class stays stable.
131
+ */
132
+ export function resetRuntimeEnv() { current = defaultEnv; }
133
+
134
+ /**
135
+ * Read-only accessor for the active environment (test / sim introspection only).
136
+ * @returns {object} The active frozen environment.
137
+ */
138
+ export function getRuntimeEnv() { return current; }