@crowdedkingdoms/crowdyjs 8.3.0 → 8.4.0

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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * ContainerMirror — the client half of the platform's **notify-to-pull**
3
+ * pattern for game-model state: keep typed snapshots of the containers you
4
+ * care about, re-pull them on demand or whenever a bound channel pings
5
+ * ("state changed"), and render straight from the cache.
6
+ *
7
+ * Model changes are pull-based on this platform (there is no model
8
+ * subscription); functions declare channel/spatial notifications and clients
9
+ * re-read. The Game Kit's match layer pings a per-match channel — bind the
10
+ * mirror to that channel and every watched container refreshes itself.
11
+ */
12
+ /**
13
+ * The SDK-managed **model mirror**: typed, cached snapshots of watched
14
+ * game-model containers with coalesced refresh and channel-ping binding.
15
+ * Reads are synchronous ({@link get}); {@link onChange} fires only when a
16
+ * refresh actually changed the visible state.
17
+ */
18
+ export class ContainerMirror {
19
+ constructor(ctx, config = {}) {
20
+ this.ctx = ctx;
21
+ this.watches = new Map();
22
+ this.listeners = new Set();
23
+ this.boundChannels = new Set();
24
+ this.refreshing = false;
25
+ this.refreshQueued = false;
26
+ this.now = config.now ?? Date.now;
27
+ // One listener serves every bound channel: a ping means "model state
28
+ // changed somewhere relevant" → coalesced refresh of all watches.
29
+ ctx.onDispose(ctx.on('channelMessage', (notification) => {
30
+ if (!this.boundChannels.has(String(notification.channelId)))
31
+ return;
32
+ void this.refreshAll();
33
+ }));
34
+ }
35
+ /**
36
+ * Watch a container: fetches the initial snapshot and keeps it refreshable.
37
+ * `parse` maps the visible properties object to your type (defaults to the
38
+ * raw object).
39
+ */
40
+ async watch(containerId, parse) {
41
+ const entry = {
42
+ parse: parse ?? ((props) => props),
43
+ snapshot: null,
44
+ lastSerialized: null,
45
+ };
46
+ this.watches.set(containerId, entry);
47
+ await this.refresh(containerId);
48
+ return entry.snapshot;
49
+ }
50
+ /** Stop watching a container (its snapshot is dropped). */
51
+ unwatch(containerId) {
52
+ this.watches.delete(containerId);
53
+ }
54
+ /** The current snapshot of a watched container (undefined before watch resolves). */
55
+ get(containerId) {
56
+ return (this.watches.get(containerId)?.snapshot ?? undefined);
57
+ }
58
+ /** Every watched snapshot. */
59
+ list() {
60
+ const out = [];
61
+ for (const entry of this.watches.values()) {
62
+ if (entry.snapshot)
63
+ out.push(entry.snapshot);
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Subscribe to snapshot changes — every watched container, or one
69
+ * `containerId`. Fires only when a refresh changed the visible state.
70
+ * @returns off.
71
+ */
72
+ onChange(handler, containerId) {
73
+ const entry = { containerId, handler };
74
+ this.listeners.add(entry);
75
+ return () => this.listeners.delete(entry);
76
+ }
77
+ /**
78
+ * Bind a channel: any message on it triggers a coalesced {@link refreshAll}
79
+ * — pair with model functions that declare channel notifications (e.g. the
80
+ * Game Kit's `match_changed` pings).
81
+ */
82
+ bindToChannel(channelId) {
83
+ this.boundChannels.add(String(channelId));
84
+ return () => this.boundChannels.delete(String(channelId));
85
+ }
86
+ /** Re-pull one watched container now. */
87
+ async refresh(containerId) {
88
+ const entry = this.watches.get(containerId);
89
+ if (!entry)
90
+ return;
91
+ const state = await this.ctx.client.gameModel.containerState({
92
+ appId: this.ctx.appId,
93
+ containerId,
94
+ });
95
+ let properties;
96
+ try {
97
+ properties = JSON.parse(state.propertiesJson);
98
+ }
99
+ catch {
100
+ properties = {};
101
+ }
102
+ const serialized = `${state.displayName}|${state.propertiesJson}`;
103
+ const changed = serialized !== entry.lastSerialized;
104
+ entry.lastSerialized = serialized;
105
+ if (!entry.snapshot) {
106
+ entry.snapshot = {
107
+ containerId,
108
+ typeName: state.typeName,
109
+ displayName: state.displayName,
110
+ ownerUserId: state.ownerUserId != null ? String(state.ownerUserId) : null,
111
+ value: entry.parse(properties),
112
+ revision: 1,
113
+ refreshedAt: this.now(),
114
+ };
115
+ }
116
+ else {
117
+ entry.snapshot.refreshedAt = this.now();
118
+ if (changed) {
119
+ entry.snapshot.typeName = state.typeName;
120
+ entry.snapshot.displayName = state.displayName;
121
+ entry.snapshot.ownerUserId =
122
+ state.ownerUserId != null ? String(state.ownerUserId) : null;
123
+ entry.snapshot.value = entry.parse(properties);
124
+ entry.snapshot.revision += 1;
125
+ }
126
+ }
127
+ if (changed) {
128
+ for (const listener of [...this.listeners]) {
129
+ if (listener.containerId === undefined ||
130
+ listener.containerId === containerId) {
131
+ listener.handler(entry.snapshot);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ /**
137
+ * Re-pull every watched container. Concurrent calls coalesce: a refresh
138
+ * requested while one is running queues exactly one follow-up pass (pings
139
+ * can burst; state converges without stampeding the API).
140
+ */
141
+ async refreshAll() {
142
+ if (this.refreshing) {
143
+ this.refreshQueued = true;
144
+ return;
145
+ }
146
+ this.refreshing = true;
147
+ try {
148
+ do {
149
+ this.refreshQueued = false;
150
+ await Promise.all([...this.watches.keys()].map((id) => this.refresh(id).catch(() => {
151
+ // A failed pull keeps the previous snapshot; the next ping retries.
152
+ })));
153
+ } while (this.refreshQueued);
154
+ }
155
+ finally {
156
+ this.refreshing = false;
157
+ }
158
+ }
159
+ }
160
+ /** Attach a {@link ContainerMirror}. Prefer the `model` config key. */
161
+ export function attachContainerMirror(ctx, config = {}) {
162
+ return new ContainerMirror(ctx, config);
163
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The World Session — the wiring hub of the World Stores layer.
3
+ *
4
+ * `createWorldSession(client, appId, config)` opens at most ONE
5
+ * `udpNotifications` subscription and fans every notification out to the
6
+ * stores you configured (decode once, route everywhere), replacing the
7
+ * hand-written "NetworkManager singleton" pattern every game rebuilds.
8
+ * Stores are opt-in twice over: only configured stores are constructed
9
+ * (runtime), and only imported store modules end up in your bundle
10
+ * (compile time — the layer lives behind the `@crowdedkingdoms/crowdyjs/stores`
11
+ * subpath and the core client never imports it).
12
+ *
13
+ * Render-loop contract: stores never touch `requestAnimationFrame`. Writes
14
+ * happen in WebSocket message handlers (not throttled in hidden tabs) and
15
+ * reads are synchronous snapshots, so a paused render loop simply catches up
16
+ * on resume. Timer-driven behaviors run on the session {@link Ticker} — pass
17
+ * `workerTicker()` to keep them at full rate in backgrounded tabs.
18
+ */
19
+ import type { AvatarsAPI } from '../domains/avatars.js';
20
+ import type { ChunksAPI } from '../domains/chunks.js';
21
+ import type { GameModelAPI } from '../domains/gameModel.js';
22
+ import type { HostAPI } from '../domains/host.js';
23
+ import type { StateAPI } from '../domains/state.js';
24
+ import type { UdpAPI } from '../domains/udp.js';
25
+ import type { UdpNotificationHandlers } from '../realtime.js';
26
+ import { type Ticker } from './ticker.js';
27
+ /**
28
+ * The sub-clients the stores compose — structurally satisfied by a
29
+ * `CrowdyClient`, so `createWorldSession(client, appId, ...)` just works;
30
+ * tests pass stubs.
31
+ */
32
+ export interface WorldStoresClient {
33
+ udp: UdpAPI;
34
+ chunks: ChunksAPI;
35
+ state: StateAPI;
36
+ avatars: AvatarsAPI;
37
+ host: HostAPI;
38
+ gameModel: GameModelAPI;
39
+ }
40
+ /** The kinds of outbound sends the session can attribute errors to. */
41
+ export type SentPacketKind = 'actorUpdate' | 'voxelUpdate' | 'text' | 'clientEvent' | 'audio' | 'singleActorMessage' | 'channelMessage';
42
+ /** A record of one outbound send, kept so errors can be attributed. */
43
+ export interface SentPacketRecord {
44
+ kind: SentPacketKind;
45
+ sequenceNumber: number;
46
+ sentAt: number;
47
+ /** The sending actor uuid, when the send had one. */
48
+ uuid?: string;
49
+ /** Optional app-relevant detail (voxel coords, channel id, …). */
50
+ detail?: Record<string, unknown>;
51
+ }
52
+ /** A listener registration on the session's notification bus. */
53
+ export type BusKey = keyof UdpNotificationHandlers;
54
+ /**
55
+ * The internal context handed to each store: the shared notification bus
56
+ * (lazy single subscription), the shared ticker, send tracking, and the
57
+ * domains. Exposed for custom store implementations; regular apps never
58
+ * touch it.
59
+ */
60
+ export interface WorldSessionContext {
61
+ readonly appId: string;
62
+ readonly client: WorldStoresClient;
63
+ readonly ticker: Ticker;
64
+ /**
65
+ * Listen for one notification kind. The first listener opens the shared
66
+ * `udpNotifications` subscription; disposing the session closes it.
67
+ * @returns An off function for this listener.
68
+ */
69
+ on<K extends BusKey>(key: K, listener: NonNullable<UdpNotificationHandlers[K]>): () => void;
70
+ /**
71
+ * Record an outbound send so a later `GenericErrorResponse` with the same
72
+ * `sequenceNumber` can be attributed (consumed by the error store; a no-op
73
+ * until one registers).
74
+ */
75
+ trackSend(record: SentPacketRecord): void;
76
+ /** Replace the send-tracking sink (registered by the error store). */
77
+ setSendTracker(sink: (record: SentPacketRecord) => void): void;
78
+ /** Register cleanup to run on session dispose. */
79
+ onDispose(cleanup: () => void): void;
80
+ }
81
+ /**
82
+ * The session core: one lazy subscription, a per-kind listener registry, a
83
+ * shared ticker, send tracking, and dispose. Store modules build on this via
84
+ * their `attach*` factories; `createWorldSession` composes them.
85
+ */
86
+ export declare class WorldSessionCore implements WorldSessionContext {
87
+ readonly appId: string;
88
+ readonly client: WorldStoresClient;
89
+ readonly ticker: Ticker;
90
+ private readonly listeners;
91
+ private readonly cleanups;
92
+ private unsubscribe;
93
+ private sendTracker;
94
+ private readonly ownsTicker;
95
+ private disposed;
96
+ constructor(client: WorldStoresClient, appId: string, ticker?: Ticker);
97
+ on<K extends BusKey>(key: K, listener: NonNullable<UdpNotificationHandlers[K]>): () => void;
98
+ trackSend(record: SentPacketRecord): void;
99
+ setSendTracker(sink: (record: SentPacketRecord) => void): void;
100
+ onDispose(cleanup: () => void): void;
101
+ /** Close the subscription, cancel timers, and run store cleanups. */
102
+ dispose(): void;
103
+ /** Open the single shared subscription on first listener. */
104
+ private ensureSubscribed;
105
+ }
106
+ /**
107
+ * Base configuration every session accepts; store-specific keys are added by
108
+ * the store modules (see `createWorldSession` in `stores/index.ts`).
109
+ */
110
+ export interface WorldSessionBaseConfig {
111
+ /**
112
+ * Scheduler for timer-driven store behaviors (send loop, reaping,
113
+ * write-back, heartbeats). Defaults to `intervalTicker()`; pass
114
+ * `workerTicker()` to keep full rate in backgrounded browser tabs. A
115
+ * caller-supplied ticker is NOT disposed with the session.
116
+ */
117
+ ticker?: Ticker;
118
+ }
119
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/stores/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The World Session — the wiring hub of the World Stores layer.
3
+ *
4
+ * `createWorldSession(client, appId, config)` opens at most ONE
5
+ * `udpNotifications` subscription and fans every notification out to the
6
+ * stores you configured (decode once, route everywhere), replacing the
7
+ * hand-written "NetworkManager singleton" pattern every game rebuilds.
8
+ * Stores are opt-in twice over: only configured stores are constructed
9
+ * (runtime), and only imported store modules end up in your bundle
10
+ * (compile time — the layer lives behind the `@crowdedkingdoms/crowdyjs/stores`
11
+ * subpath and the core client never imports it).
12
+ *
13
+ * Render-loop contract: stores never touch `requestAnimationFrame`. Writes
14
+ * happen in WebSocket message handlers (not throttled in hidden tabs) and
15
+ * reads are synchronous snapshots, so a paused render loop simply catches up
16
+ * on resume. Timer-driven behaviors run on the session {@link Ticker} — pass
17
+ * `workerTicker()` to keep them at full rate in backgrounded tabs.
18
+ */
19
+ import { intervalTicker } from './ticker.js';
20
+ /** All handler keys the fan-out dispatches (mirrors {@link UdpNotificationHandlers}). */
21
+ const BUS_KEYS = [
22
+ 'actorUpdate',
23
+ 'actorUpdateResponse',
24
+ 'voxelUpdate',
25
+ 'voxelUpdateResponse',
26
+ 'audio',
27
+ 'text',
28
+ 'clientEvent',
29
+ 'serverEvent',
30
+ 'singleActorMessage',
31
+ 'channelMessage',
32
+ 'genericError',
33
+ 'connectionEvent',
34
+ 'any',
35
+ 'error',
36
+ ];
37
+ /**
38
+ * The session core: one lazy subscription, a per-kind listener registry, a
39
+ * shared ticker, send tracking, and dispose. Store modules build on this via
40
+ * their `attach*` factories; `createWorldSession` composes them.
41
+ */
42
+ export class WorldSessionCore {
43
+ constructor(client, appId, ticker) {
44
+ this.listeners = new Map();
45
+ this.cleanups = [];
46
+ this.unsubscribe = null;
47
+ this.sendTracker = null;
48
+ this.disposed = false;
49
+ this.client = client;
50
+ this.appId = appId;
51
+ this.ownsTicker = ticker === undefined;
52
+ this.ticker = ticker ?? intervalTicker();
53
+ }
54
+ on(key, listener) {
55
+ let set = this.listeners.get(key);
56
+ if (!set) {
57
+ set = new Set();
58
+ this.listeners.set(key, set);
59
+ }
60
+ set.add(listener);
61
+ this.ensureSubscribed();
62
+ return () => {
63
+ set.delete(listener);
64
+ };
65
+ }
66
+ trackSend(record) {
67
+ this.sendTracker?.(record);
68
+ }
69
+ setSendTracker(sink) {
70
+ this.sendTracker = sink;
71
+ }
72
+ onDispose(cleanup) {
73
+ this.cleanups.push(cleanup);
74
+ }
75
+ /** Close the subscription, cancel timers, and run store cleanups. */
76
+ dispose() {
77
+ if (this.disposed)
78
+ return;
79
+ this.disposed = true;
80
+ for (const cleanup of this.cleanups.splice(0)) {
81
+ try {
82
+ cleanup();
83
+ }
84
+ catch {
85
+ // Cleanup must never mask other cleanups.
86
+ }
87
+ }
88
+ this.unsubscribe?.();
89
+ this.unsubscribe = null;
90
+ this.listeners.clear();
91
+ if (this.ownsTicker)
92
+ this.ticker.dispose();
93
+ }
94
+ /** Open the single shared subscription on first listener. */
95
+ ensureSubscribed() {
96
+ if (this.unsubscribe || this.disposed)
97
+ return;
98
+ const handlers = {};
99
+ for (const key of BUS_KEYS) {
100
+ handlers[key] = (notification) => {
101
+ const set = this.listeners.get(key);
102
+ if (!set)
103
+ return;
104
+ for (const listener of [...set]) {
105
+ try {
106
+ listener(notification);
107
+ }
108
+ catch {
109
+ // One listener's throw must not starve the others.
110
+ }
111
+ }
112
+ };
113
+ }
114
+ this.unsubscribe = this.client.udp.subscribe(handlers, this.appId);
115
+ }
116
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The scheduler behind every timer-driven store behavior (the actor send
3
+ * loop, stale reaping, chunk write-back throttling, the host heartbeat).
4
+ *
5
+ * Why an abstraction: browsers throttle main-thread timers in hidden tabs
6
+ * (≥1 s immediately, down to ~1/minute under intensive throttling), which
7
+ * would silently drop a backgrounded player's presence rate. Timers inside a
8
+ * **dedicated Web Worker are exempt**, so games that need full-rate sends
9
+ * while backgrounded pass {@link workerTicker} to `createWorldSession`;
10
+ * everything else defaults to {@link intervalTicker}. Tests drive stores
11
+ * deterministically with {@link manualTicker}.
12
+ *
13
+ * (WebSocket message delivery is NOT throttled in hidden tabs, so inbound
14
+ * store updates flow regardless of the ticker choice.)
15
+ */
16
+ /** A cancellable repeating-callback scheduler. */
17
+ export interface Ticker {
18
+ /**
19
+ * Invoke `callback` every `intervalMs` until cancelled.
20
+ * @returns A cancel function for this schedule.
21
+ */
22
+ every(intervalMs: number, callback: () => void): () => void;
23
+ /** Cancel every schedule and release resources (e.g. terminate the worker). */
24
+ dispose(): void;
25
+ }
26
+ /** Plain `setInterval` ticker — the default. Subject to background-tab throttling. */
27
+ export declare function intervalTicker(): Ticker;
28
+ /**
29
+ * A ticker whose intervals run inside an inline dedicated Web Worker —
30
+ * exempt from background-tab timer throttling, so a hidden tab keeps
31
+ * sending presence at full rate. Falls back to {@link intervalTicker} in
32
+ * runtimes without `Worker`/`Blob` support (Node, SSR).
33
+ */
34
+ export declare function workerTicker(): Ticker;
35
+ /** A {@link manualTicker}: time only moves when the test calls `advance`. */
36
+ export interface ManualTicker extends Ticker {
37
+ /** Advance virtual time, firing due callbacks (repeatedly, in due order). */
38
+ advance(ms: number): void;
39
+ /** The current virtual time in ms. */
40
+ readonly now: number;
41
+ }
42
+ /** Deterministic ticker for tests — no real timers. */
43
+ export declare function manualTicker(): ManualTicker;
44
+ //# sourceMappingURL=ticker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ticker.d.ts","sourceRoot":"","sources":["../../src/stores/ticker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,kDAAkD;AAClD,MAAM,WAAW,MAAM;IACrB;;;OAGG;IACH,KAAK,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;IAC5D,+EAA+E;IAC/E,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,sFAAsF;AACtF,wBAAgB,cAAc,IAAI,MAAM,CAgBvC;AAeD;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,MAAM,CA6CrC;AAED,6EAA6E;AAC7E,MAAM,WAAW,YAAa,SAAQ,MAAM;IAC1C,6EAA6E;IAC7E,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,sCAAsC;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,uDAAuD;AACvD,wBAAgB,YAAY,IAAI,YAAY,CAuC3C"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The scheduler behind every timer-driven store behavior (the actor send
3
+ * loop, stale reaping, chunk write-back throttling, the host heartbeat).
4
+ *
5
+ * Why an abstraction: browsers throttle main-thread timers in hidden tabs
6
+ * (≥1 s immediately, down to ~1/minute under intensive throttling), which
7
+ * would silently drop a backgrounded player's presence rate. Timers inside a
8
+ * **dedicated Web Worker are exempt**, so games that need full-rate sends
9
+ * while backgrounded pass {@link workerTicker} to `createWorldSession`;
10
+ * everything else defaults to {@link intervalTicker}. Tests drive stores
11
+ * deterministically with {@link manualTicker}.
12
+ *
13
+ * (WebSocket message delivery is NOT throttled in hidden tabs, so inbound
14
+ * store updates flow regardless of the ticker choice.)
15
+ */
16
+ /** Plain `setInterval` ticker — the default. Subject to background-tab throttling. */
17
+ export function intervalTicker() {
18
+ const handles = new Set();
19
+ return {
20
+ every(intervalMs, callback) {
21
+ const handle = setInterval(callback, intervalMs);
22
+ handles.add(handle);
23
+ return () => {
24
+ clearInterval(handle);
25
+ handles.delete(handle);
26
+ };
27
+ },
28
+ dispose() {
29
+ for (const handle of handles)
30
+ clearInterval(handle);
31
+ handles.clear();
32
+ },
33
+ };
34
+ }
35
+ const WORKER_SOURCE = `
36
+ const timers = new Map();
37
+ self.onmessage = (event) => {
38
+ const { id, intervalMs, cancel } = event.data;
39
+ if (cancel) {
40
+ const handle = timers.get(id);
41
+ if (handle !== undefined) { clearInterval(handle); timers.delete(id); }
42
+ return;
43
+ }
44
+ timers.set(id, setInterval(() => self.postMessage({ id }), intervalMs));
45
+ };
46
+ `;
47
+ /**
48
+ * A ticker whose intervals run inside an inline dedicated Web Worker —
49
+ * exempt from background-tab timer throttling, so a hidden tab keeps
50
+ * sending presence at full rate. Falls back to {@link intervalTicker} in
51
+ * runtimes without `Worker`/`Blob` support (Node, SSR).
52
+ */
53
+ export function workerTicker() {
54
+ const g = globalThis;
55
+ if (!g.Worker || !g.Blob || !g.URL?.createObjectURL) {
56
+ return intervalTicker();
57
+ }
58
+ const url = g.URL.createObjectURL(new g.Blob([WORKER_SOURCE], { type: 'application/javascript' }));
59
+ let worker;
60
+ try {
61
+ worker = new g.Worker(url);
62
+ }
63
+ catch {
64
+ g.URL.revokeObjectURL(url);
65
+ return intervalTicker();
66
+ }
67
+ const callbacks = new Map();
68
+ let nextId = 1;
69
+ worker.onmessage = (event) => {
70
+ callbacks.get(event.data.id)?.();
71
+ };
72
+ return {
73
+ every(intervalMs, callback) {
74
+ if (!worker)
75
+ return () => { };
76
+ const id = nextId++;
77
+ callbacks.set(id, callback);
78
+ worker.postMessage({ id, intervalMs });
79
+ return () => {
80
+ callbacks.delete(id);
81
+ worker?.postMessage({ id, cancel: true });
82
+ };
83
+ },
84
+ dispose() {
85
+ callbacks.clear();
86
+ worker?.terminate();
87
+ worker = null;
88
+ g.URL?.revokeObjectURL(url);
89
+ },
90
+ };
91
+ }
92
+ /** Deterministic ticker for tests — no real timers. */
93
+ export function manualTicker() {
94
+ const tasks = new Set();
95
+ let now = 0;
96
+ return {
97
+ get now() {
98
+ return now;
99
+ },
100
+ every(intervalMs, callback) {
101
+ const task = { intervalMs, callback, nextAt: now + intervalMs };
102
+ tasks.add(task);
103
+ return () => tasks.delete(task);
104
+ },
105
+ advance(ms) {
106
+ const end = now + ms;
107
+ // Fire tasks in due order until none are due before `end`.
108
+ for (;;) {
109
+ let next = null;
110
+ for (const task of tasks) {
111
+ if (task.nextAt <= end && (next === null || task.nextAt < next.nextAt)) {
112
+ next = task;
113
+ }
114
+ }
115
+ if (!next)
116
+ break;
117
+ now = next.nextAt;
118
+ next.nextAt += next.intervalMs;
119
+ next.callback();
120
+ }
121
+ now = end;
122
+ },
123
+ dispose() {
124
+ tasks.clear();
125
+ },
126
+ };
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdedkingdoms/crowdyjs",
3
- "version": "8.3.0",
3
+ "version": "8.4.0",
4
4
  "description": "Client SDK for Crowded Kingdoms GraphQL API with UDP proxy support",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,8 +13,13 @@
13
13
  "./generated": {
14
14
  "import": "./dist/generated/graphql.js",
15
15
  "types": "./dist/generated/graphql.d.ts"
16
+ },
17
+ "./stores": {
18
+ "import": "./dist/stores/index.js",
19
+ "types": "./dist/stores/index.d.ts"
16
20
  }
17
21
  },
22
+ "sideEffects": false,
18
23
  "files": [
19
24
  "dist",
20
25
  "README.md",