@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.
- package/README.md +60 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/stores/actors.d.ts +316 -0
- package/dist/stores/actors.d.ts.map +1 -0
- package/dist/stores/actors.js +506 -0
- package/dist/stores/chunks.d.ts +180 -0
- package/dist/stores/chunks.d.ts.map +1 -0
- package/dist/stores/chunks.js +378 -0
- package/dist/stores/codec.d.ts +109 -0
- package/dist/stores/codec.d.ts.map +1 -0
- package/dist/stores/codec.js +186 -0
- package/dist/stores/durable.d.ts +144 -0
- package/dist/stores/durable.d.ts.map +1 -0
- package/dist/stores/durable.js +246 -0
- package/dist/stores/errors.d.ts +69 -0
- package/dist/stores/errors.d.ts.map +1 -0
- package/dist/stores/errors.js +87 -0
- package/dist/stores/inbox.d.ts +184 -0
- package/dist/stores/inbox.d.ts.map +1 -0
- package/dist/stores/inbox.js +326 -0
- package/dist/stores/index.d.ts +186 -0
- package/dist/stores/index.d.ts.map +1 -0
- package/dist/stores/index.js +109 -0
- package/dist/stores/keys.d.ts +52 -0
- package/dist/stores/keys.d.ts.map +1 -0
- package/dist/stores/keys.js +76 -0
- package/dist/stores/model.d.ts +81 -0
- package/dist/stores/model.d.ts.map +1 -0
- package/dist/stores/model.js +163 -0
- package/dist/stores/session.d.ts +119 -0
- package/dist/stores/session.d.ts.map +1 -0
- package/dist/stores/session.js +116 -0
- package/dist/stores/ticker.d.ts +44 -0
- package/dist/stores/ticker.d.ts.map +1 -0
- package/dist/stores/ticker.js +127 -0
- package/package.json +6 -1
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actor stores — the SDK-managed bookkeeping every multiplayer game
|
|
3
|
+
* otherwise hand-writes: your own actor's identity, typed state, and send
|
|
4
|
+
* loop ({@link LocalActorStore}), created via {@link attachLocalActor} on a
|
|
5
|
+
* world session.
|
|
6
|
+
*/
|
|
7
|
+
import { SequenceAllocator, generateCrowdyUuid, validateCrowdyUuid } from '../utils.js';
|
|
8
|
+
/** Keep the uuid for this tab session only (a fresh actor per reload). */
|
|
9
|
+
export function memoryUuidStore() {
|
|
10
|
+
let value = null;
|
|
11
|
+
return {
|
|
12
|
+
load: () => value,
|
|
13
|
+
save: (uuid) => {
|
|
14
|
+
value = uuid;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Persist the uuid in `localStorage` so the player keeps a stable actor
|
|
20
|
+
* identity across reloads (the convention games converge on). Falls back to
|
|
21
|
+
* {@link memoryUuidStore} behavior outside the browser.
|
|
22
|
+
*/
|
|
23
|
+
export function localStorageUuidStore(key = 'crowdyjs:actor-uuid') {
|
|
24
|
+
const storage = globalThis.localStorage;
|
|
25
|
+
if (!storage)
|
|
26
|
+
return memoryUuidStore();
|
|
27
|
+
return {
|
|
28
|
+
load: () => {
|
|
29
|
+
try {
|
|
30
|
+
return storage.getItem(key);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
save: (uuid) => {
|
|
37
|
+
try {
|
|
38
|
+
storage.setItem(key, uuid);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Quota/permission failures degrade to per-session identity.
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The SDK-managed **local actor**: identity (minted + persisted uuid), typed
|
|
48
|
+
* replication state, current chunk, an automatic 5 Hz send loop with
|
|
49
|
+
* send-on-change dedup, and queryable send bookkeeping — {@link lastSent},
|
|
50
|
+
* {@link lastAck} (the server-applied self-echo), {@link lastError}, and
|
|
51
|
+
* {@link status}. Replaces the hand-written codec + sender + uuid plumbing
|
|
52
|
+
* every game rebuilds.
|
|
53
|
+
*
|
|
54
|
+
* Reads are synchronous; all record updates happen on WebSocket events, so
|
|
55
|
+
* the render loop can query freely regardless of tab visibility.
|
|
56
|
+
*/
|
|
57
|
+
export class LocalActorStore {
|
|
58
|
+
constructor(ctx, config) {
|
|
59
|
+
this.ctx = ctx;
|
|
60
|
+
this.config = config;
|
|
61
|
+
this.currentChunk = null;
|
|
62
|
+
this.lastSentRecord = null;
|
|
63
|
+
this.lastAckRecord = null;
|
|
64
|
+
this.lastErrorRecord = null;
|
|
65
|
+
this.inFlight = new Map(); // seq → sentAt
|
|
66
|
+
this.sequences = new SequenceAllocator();
|
|
67
|
+
this.now = config.now ?? Date.now;
|
|
68
|
+
this.currentState = config.initialState;
|
|
69
|
+
// Identity: explicit uuid > persisted uuid > freshly minted (persisted).
|
|
70
|
+
const uuidStore = config.uuidStore ?? memoryUuidStore();
|
|
71
|
+
let uuid = config.uuid ?? uuidStore.load();
|
|
72
|
+
if (!uuid) {
|
|
73
|
+
uuid = generateCrowdyUuid();
|
|
74
|
+
}
|
|
75
|
+
validateCrowdyUuid(uuid);
|
|
76
|
+
uuidStore.save(uuid);
|
|
77
|
+
this.uuid = uuid;
|
|
78
|
+
// Self-echo: the server includes the sender in the chunk fan-out, so our
|
|
79
|
+
// own applied update arrives as an actorUpdate with our uuid.
|
|
80
|
+
ctx.onDispose(ctx.on('actorUpdate', (notification) => {
|
|
81
|
+
if (notification.uuid !== this.uuid)
|
|
82
|
+
return;
|
|
83
|
+
let state;
|
|
84
|
+
try {
|
|
85
|
+
state = this.config.codec.decode(notification.state);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return; // Not our layout (shouldn't happen for our own echo).
|
|
89
|
+
}
|
|
90
|
+
this.lastAckRecord = { state, notification, receivedAt: this.now() };
|
|
91
|
+
this.inFlight.delete(notification.sequenceNumber);
|
|
92
|
+
}));
|
|
93
|
+
// Attribute send errors to our in-flight sequence numbers.
|
|
94
|
+
ctx.onDispose(ctx.on('genericError', (notification) => {
|
|
95
|
+
if (!this.inFlight.has(notification.sequenceNumber))
|
|
96
|
+
return;
|
|
97
|
+
this.inFlight.delete(notification.sequenceNumber);
|
|
98
|
+
this.lastErrorRecord = {
|
|
99
|
+
errorCode: String(notification.errorCode),
|
|
100
|
+
sequenceNumber: notification.sequenceNumber,
|
|
101
|
+
receivedAt: this.now(),
|
|
102
|
+
};
|
|
103
|
+
}));
|
|
104
|
+
// The send loop (5 Hz default) on the shared session ticker.
|
|
105
|
+
const interval = config.sendIntervalMs ?? 200;
|
|
106
|
+
if (interval !== false && interval > 0) {
|
|
107
|
+
ctx.onDispose(ctx.ticker.every(interval, () => this.tick()));
|
|
108
|
+
}
|
|
109
|
+
// Tab-return re-registration.
|
|
110
|
+
const doc = globalThis.document;
|
|
111
|
+
if ((config.refreshOnVisibility ?? true) && doc?.addEventListener) {
|
|
112
|
+
const onVisibility = () => {
|
|
113
|
+
if (doc.visibilityState === 'visible')
|
|
114
|
+
void this.refresh('visibility');
|
|
115
|
+
};
|
|
116
|
+
doc.addEventListener('visibilitychange', onVisibility);
|
|
117
|
+
ctx.onDispose(() => doc.removeEventListener('visibilitychange', onVisibility));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The current typed replication state (what the loop sends). */
|
|
121
|
+
get state() {
|
|
122
|
+
return this.currentState;
|
|
123
|
+
}
|
|
124
|
+
/** The actor's current chunk (null before {@link join}). */
|
|
125
|
+
get chunk() {
|
|
126
|
+
return this.currentChunk;
|
|
127
|
+
}
|
|
128
|
+
/** The most recent outbound update (typed + encoded + seq + timestamp). */
|
|
129
|
+
get lastSent() {
|
|
130
|
+
return this.lastSentRecord;
|
|
131
|
+
}
|
|
132
|
+
/** The most recent server-applied self-echo. */
|
|
133
|
+
get lastAck() {
|
|
134
|
+
return this.lastAckRecord;
|
|
135
|
+
}
|
|
136
|
+
/** The most recent send error attributed to this actor. */
|
|
137
|
+
get lastError() {
|
|
138
|
+
return this.lastErrorRecord;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Replication lifecycle: `idle` (nothing sent), `pending` (sent, no echo
|
|
142
|
+
* yet), `acked` (echo at or after the last send), `error` (an error
|
|
143
|
+
* arrived after the last send).
|
|
144
|
+
*/
|
|
145
|
+
get status() {
|
|
146
|
+
if (!this.lastSentRecord)
|
|
147
|
+
return 'idle';
|
|
148
|
+
if (this.lastErrorRecord &&
|
|
149
|
+
this.lastErrorRecord.receivedAt >= this.lastSentRecord.sentAt) {
|
|
150
|
+
return 'error';
|
|
151
|
+
}
|
|
152
|
+
if (this.lastAckRecord &&
|
|
153
|
+
this.lastAckRecord.receivedAt >= this.lastSentRecord.sentAt) {
|
|
154
|
+
return 'acked';
|
|
155
|
+
}
|
|
156
|
+
return 'pending';
|
|
157
|
+
}
|
|
158
|
+
/** Update the typed state; the next loop tick (or `sendNow`) sends it. */
|
|
159
|
+
setState(state) {
|
|
160
|
+
this.currentState = state;
|
|
161
|
+
}
|
|
162
|
+
/** Merge a partial update into the typed state (object states only). */
|
|
163
|
+
patchState(patch) {
|
|
164
|
+
this.currentState = { ...this.currentState, ...patch };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Enter a chunk: records it as the actor's current chunk and immediately
|
|
168
|
+
* sends presence there. (The first message to a brand-new chunk may be
|
|
169
|
+
* dropped server-side while grid permissions load — if {@link status}
|
|
170
|
+
* stays `pending`, call {@link refresh}.)
|
|
171
|
+
*/
|
|
172
|
+
async join(chunk, state) {
|
|
173
|
+
this.currentChunk = chunk;
|
|
174
|
+
if (state !== undefined)
|
|
175
|
+
this.currentState = state;
|
|
176
|
+
await this.send('join');
|
|
177
|
+
}
|
|
178
|
+
/** Move to another chunk and immediately send presence there. */
|
|
179
|
+
async moveTo(chunk) {
|
|
180
|
+
this.currentChunk = chunk;
|
|
181
|
+
await this.send('move');
|
|
182
|
+
}
|
|
183
|
+
/** Send the current state now, bypassing dedup. */
|
|
184
|
+
async sendNow() {
|
|
185
|
+
await this.send('manual');
|
|
186
|
+
}
|
|
187
|
+
/** Re-register presence (reconnects, tab return, dropped first join). */
|
|
188
|
+
async refresh(reason = 'refresh') {
|
|
189
|
+
if (!this.currentChunk)
|
|
190
|
+
return;
|
|
191
|
+
await this.send(reason);
|
|
192
|
+
}
|
|
193
|
+
/** One send-loop tick: dedup unchanged state, keyframe when quiet. */
|
|
194
|
+
tick() {
|
|
195
|
+
if (!this.currentChunk)
|
|
196
|
+
return;
|
|
197
|
+
const encoded = this.config.codec.encode(this.currentState);
|
|
198
|
+
if ((this.config.sendOnChange ?? true) && this.lastSentRecord) {
|
|
199
|
+
const quietFor = this.now() - this.lastSentRecord.sentAt;
|
|
200
|
+
const keyframeEvery = this.config.keyframeEveryMs ?? 3000;
|
|
201
|
+
if (encoded === this.lastSentRecord.encoded && quietFor < keyframeEvery) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (encoded === this.lastSentRecord.encoded) {
|
|
205
|
+
void this.send('keyframe', encoded);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
void this.send('interval', encoded);
|
|
210
|
+
}
|
|
211
|
+
async send(reason, preEncoded) {
|
|
212
|
+
const chunk = this.currentChunk;
|
|
213
|
+
if (!chunk) {
|
|
214
|
+
throw new Error('Local actor must join a chunk before sending');
|
|
215
|
+
}
|
|
216
|
+
const state = this.currentState;
|
|
217
|
+
const encoded = preEncoded ?? this.config.codec.encode(state);
|
|
218
|
+
const sequenceNumber = this.sequences.next();
|
|
219
|
+
const sentAt = this.now();
|
|
220
|
+
this.lastSentRecord = { state, encoded, chunk, sequenceNumber, sentAt, reason };
|
|
221
|
+
this.inFlight.set(sequenceNumber, sentAt);
|
|
222
|
+
if (this.inFlight.size > 256) {
|
|
223
|
+
const oldest = this.inFlight.keys().next().value;
|
|
224
|
+
if (oldest !== undefined)
|
|
225
|
+
this.inFlight.delete(oldest);
|
|
226
|
+
}
|
|
227
|
+
this.ctx.trackSend({
|
|
228
|
+
kind: 'actorUpdate',
|
|
229
|
+
sequenceNumber,
|
|
230
|
+
sentAt,
|
|
231
|
+
uuid: this.uuid,
|
|
232
|
+
detail: { reason },
|
|
233
|
+
});
|
|
234
|
+
await this.ctx.client.udp.sendActorUpdate({
|
|
235
|
+
appId: this.ctx.appId,
|
|
236
|
+
chunk,
|
|
237
|
+
uuid: this.uuid,
|
|
238
|
+
state: encoded,
|
|
239
|
+
sequenceNumber,
|
|
240
|
+
...(this.config.distance !== undefined ? { distance: this.config.distance } : {}),
|
|
241
|
+
...(this.config.decayRate !== undefined
|
|
242
|
+
? { decayRate: this.config.decayRate }
|
|
243
|
+
: {}),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Attach a {@link LocalActorStore} to a world session context. Prefer the
|
|
249
|
+
* `self` key of `createWorldSession`'s config; use this directly for custom
|
|
250
|
+
* compositions.
|
|
251
|
+
*/
|
|
252
|
+
export function attachLocalActor(ctx, config) {
|
|
253
|
+
return new LocalActorStore(ctx, config);
|
|
254
|
+
}
|
|
255
|
+
/** One lane's registry of remote actors. */
|
|
256
|
+
export class RemoteActorLane {
|
|
257
|
+
constructor(historySize, staleAfterMs, now) {
|
|
258
|
+
this.historySize = historySize;
|
|
259
|
+
this.staleAfterMs = staleAfterMs;
|
|
260
|
+
this.now = now;
|
|
261
|
+
this.actors = new Map();
|
|
262
|
+
this.joinListeners = new Set();
|
|
263
|
+
this.updateListeners = new Set();
|
|
264
|
+
this.leaveListeners = new Set();
|
|
265
|
+
this.revisionValue = 0;
|
|
266
|
+
}
|
|
267
|
+
/** Bumped on every change — poll it cheaply from a render loop. */
|
|
268
|
+
get revision() {
|
|
269
|
+
return this.revisionValue;
|
|
270
|
+
}
|
|
271
|
+
/** Live actors (stale ones filtered at read time), unordered. */
|
|
272
|
+
list() {
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const actor of this.actors.values()) {
|
|
275
|
+
if (!this.isStale(actor))
|
|
276
|
+
out.push(actor);
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
/** One live actor, or undefined when unknown/stale. */
|
|
281
|
+
get(uuid) {
|
|
282
|
+
const actor = this.actors.get(uuid);
|
|
283
|
+
return actor && !this.isStale(actor) ? actor : undefined;
|
|
284
|
+
}
|
|
285
|
+
/** Live actor count. */
|
|
286
|
+
get count() {
|
|
287
|
+
return this.list().length;
|
|
288
|
+
}
|
|
289
|
+
/** A new actor appeared. @returns off. */
|
|
290
|
+
onJoin(listener) {
|
|
291
|
+
this.joinListeners.add(listener);
|
|
292
|
+
return () => this.joinListeners.delete(listener);
|
|
293
|
+
}
|
|
294
|
+
/** An actor's state updated (fires after `onJoin` for the first update). @returns off. */
|
|
295
|
+
onUpdate(listener) {
|
|
296
|
+
this.updateListeners.add(listener);
|
|
297
|
+
return () => this.updateListeners.delete(listener);
|
|
298
|
+
}
|
|
299
|
+
/** An actor went stale and was reaped (or the store was cleared). @returns off. */
|
|
300
|
+
onLeave(listener) {
|
|
301
|
+
this.leaveListeners.add(listener);
|
|
302
|
+
return () => this.leaveListeners.delete(listener);
|
|
303
|
+
}
|
|
304
|
+
/** Apply one decoded update (internal). */
|
|
305
|
+
apply(uuid, state, chunk, distance, epochMillis) {
|
|
306
|
+
const receivedAt = this.now();
|
|
307
|
+
let actor = this.actors.get(uuid);
|
|
308
|
+
const isNew = !actor;
|
|
309
|
+
if (!actor) {
|
|
310
|
+
actor = {
|
|
311
|
+
uuid,
|
|
312
|
+
state,
|
|
313
|
+
chunk,
|
|
314
|
+
distance,
|
|
315
|
+
epochMillis,
|
|
316
|
+
receivedAt,
|
|
317
|
+
samples: [],
|
|
318
|
+
};
|
|
319
|
+
this.actors.set(uuid, actor);
|
|
320
|
+
}
|
|
321
|
+
actor.state = state;
|
|
322
|
+
actor.chunk = chunk;
|
|
323
|
+
actor.distance = distance;
|
|
324
|
+
actor.epochMillis = epochMillis;
|
|
325
|
+
actor.receivedAt = receivedAt;
|
|
326
|
+
actor.samples.unshift({ state, chunk, epochMillis, receivedAt });
|
|
327
|
+
if (actor.samples.length > this.historySize) {
|
|
328
|
+
actor.samples.length = this.historySize;
|
|
329
|
+
}
|
|
330
|
+
this.revisionValue += 1;
|
|
331
|
+
if (isNew) {
|
|
332
|
+
for (const listener of [...this.joinListeners])
|
|
333
|
+
listener(actor);
|
|
334
|
+
}
|
|
335
|
+
for (const listener of [...this.updateListeners])
|
|
336
|
+
listener(actor);
|
|
337
|
+
}
|
|
338
|
+
/** Physically delete stale records, firing `onLeave` for each. */
|
|
339
|
+
reap() {
|
|
340
|
+
for (const [uuid, actor] of this.actors) {
|
|
341
|
+
if (this.isStale(actor)) {
|
|
342
|
+
this.actors.delete(uuid);
|
|
343
|
+
this.revisionValue += 1;
|
|
344
|
+
for (const listener of [...this.leaveListeners])
|
|
345
|
+
listener(actor);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** Drop every record (fires `onLeave` for each live one). */
|
|
350
|
+
clear() {
|
|
351
|
+
for (const [uuid, actor] of this.actors) {
|
|
352
|
+
this.actors.delete(uuid);
|
|
353
|
+
this.revisionValue += 1;
|
|
354
|
+
for (const listener of [...this.leaveListeners])
|
|
355
|
+
listener(actor);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
isStale(actor) {
|
|
359
|
+
return (this.staleAfterMs !== false && this.now() - actor.receivedAt > this.staleAfterMs);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The SDK-managed **remote actor registry**: subscribes to `actorUpdate`,
|
|
364
|
+
* decodes each notification ONCE, filters the local self-echo, and maintains
|
|
365
|
+
* per-actor records with timestamped sample history, staleness, and
|
|
366
|
+
* join/update/leave events. With `lanes`, one decoded stream feeds several
|
|
367
|
+
* consumers (players vs mobs) without double-decoding.
|
|
368
|
+
*
|
|
369
|
+
* Reads are synchronous and always live-filtered (staleness is computed at
|
|
370
|
+
* read time), so render loops can query at any cadence — including after a
|
|
371
|
+
* backgrounded tab resumes.
|
|
372
|
+
*/
|
|
373
|
+
export class RemoteActorStore {
|
|
374
|
+
constructor(ctx, config) {
|
|
375
|
+
this.config = config;
|
|
376
|
+
this.lanes = new Map();
|
|
377
|
+
this.decodeFailureCount = 0;
|
|
378
|
+
const now = config.now ?? Date.now;
|
|
379
|
+
const historySize = Math.max(1, config.historySize ?? 2);
|
|
380
|
+
const staleAfterMs = config.staleAfterMs ?? 12000;
|
|
381
|
+
const laneNames = config.lanes ? Object.keys(config.lanes) : ['default'];
|
|
382
|
+
for (const name of laneNames) {
|
|
383
|
+
this.lanes.set(name, new RemoteActorLane(historySize, staleAfterMs, now));
|
|
384
|
+
}
|
|
385
|
+
this.laneFilters = config.lanes
|
|
386
|
+
? Object.entries(config.lanes)
|
|
387
|
+
: [['default', () => true]];
|
|
388
|
+
this.defaultLane = config.lanes ? null : this.lanes.get('default');
|
|
389
|
+
ctx.onDispose(ctx.on('actorUpdate', (notification) => {
|
|
390
|
+
const selfUuid = typeof this.config.selfUuid === 'function'
|
|
391
|
+
? this.config.selfUuid()
|
|
392
|
+
: this.config.selfUuid;
|
|
393
|
+
if (selfUuid && notification.uuid === selfUuid)
|
|
394
|
+
return;
|
|
395
|
+
let state;
|
|
396
|
+
try {
|
|
397
|
+
state = this.config.codec.decode(notification.state);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
this.decodeFailureCount += 1;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
for (const [name, filter] of this.laneFilters) {
|
|
404
|
+
let matches;
|
|
405
|
+
try {
|
|
406
|
+
matches = filter(state, notification);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!matches)
|
|
412
|
+
continue;
|
|
413
|
+
this.lanes.get(name).apply(notification.uuid, state, {
|
|
414
|
+
x: notification.chunkX,
|
|
415
|
+
y: notification.chunkY,
|
|
416
|
+
z: notification.chunkZ,
|
|
417
|
+
}, notification.distance, Number(notification.epochMillis));
|
|
418
|
+
break; // first matching lane wins
|
|
419
|
+
}
|
|
420
|
+
}));
|
|
421
|
+
const reapInterval = config.reapIntervalMs ?? 1000;
|
|
422
|
+
if (reapInterval !== false && reapInterval > 0 && staleAfterMs !== false) {
|
|
423
|
+
ctx.onDispose(ctx.ticker.every(reapInterval, () => this.reap()));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/** A named lane's registry (throws for unknown names). */
|
|
427
|
+
lane(name) {
|
|
428
|
+
const lane = this.lanes.get(name);
|
|
429
|
+
if (!lane) {
|
|
430
|
+
throw new Error(`Unknown actor lane '${name}' (configured: ${[...this.lanes.keys()].join(', ')})`);
|
|
431
|
+
}
|
|
432
|
+
return lane;
|
|
433
|
+
}
|
|
434
|
+
/** Live actors across every lane (the default lane when none configured). */
|
|
435
|
+
list() {
|
|
436
|
+
if (this.defaultLane)
|
|
437
|
+
return this.defaultLane.list();
|
|
438
|
+
const out = [];
|
|
439
|
+
for (const lane of this.lanes.values())
|
|
440
|
+
out.push(...lane.list());
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
/** One live actor, searched across lanes. */
|
|
444
|
+
get(uuid) {
|
|
445
|
+
for (const lane of this.lanes.values()) {
|
|
446
|
+
const actor = lane.get(uuid);
|
|
447
|
+
if (actor)
|
|
448
|
+
return actor;
|
|
449
|
+
}
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
/** Live actor count across lanes. */
|
|
453
|
+
get count() {
|
|
454
|
+
let total = 0;
|
|
455
|
+
for (const lane of this.lanes.values())
|
|
456
|
+
total += lane.count;
|
|
457
|
+
return total;
|
|
458
|
+
}
|
|
459
|
+
/** Sum of lane revisions — poll it cheaply from a render loop. */
|
|
460
|
+
get revision() {
|
|
461
|
+
let total = 0;
|
|
462
|
+
for (const lane of this.lanes.values())
|
|
463
|
+
total += lane.revision;
|
|
464
|
+
return total;
|
|
465
|
+
}
|
|
466
|
+
/** Notifications whose state failed to decode (foreign layouts). */
|
|
467
|
+
get decodeFailures() {
|
|
468
|
+
return this.decodeFailureCount;
|
|
469
|
+
}
|
|
470
|
+
/** A new actor appeared (default/single-lane sugar; use `lane()` with lanes). */
|
|
471
|
+
onJoin(listener) {
|
|
472
|
+
return this.everyLane((lane) => lane.onJoin(listener));
|
|
473
|
+
}
|
|
474
|
+
/** An actor updated. */
|
|
475
|
+
onUpdate(listener) {
|
|
476
|
+
return this.everyLane((lane) => lane.onUpdate(listener));
|
|
477
|
+
}
|
|
478
|
+
/** An actor was reaped. */
|
|
479
|
+
onLeave(listener) {
|
|
480
|
+
return this.everyLane((lane) => lane.onLeave(listener));
|
|
481
|
+
}
|
|
482
|
+
/** Physically delete stale records in every lane. */
|
|
483
|
+
reap() {
|
|
484
|
+
for (const lane of this.lanes.values())
|
|
485
|
+
lane.reap();
|
|
486
|
+
}
|
|
487
|
+
/** Drop every record in every lane. */
|
|
488
|
+
clear() {
|
|
489
|
+
for (const lane of this.lanes.values())
|
|
490
|
+
lane.clear();
|
|
491
|
+
}
|
|
492
|
+
everyLane(register) {
|
|
493
|
+
const offs = [...this.lanes.values()].map(register);
|
|
494
|
+
return () => {
|
|
495
|
+
for (const off of offs)
|
|
496
|
+
off();
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Attach a {@link RemoteActorStore} to a world session context. Prefer the
|
|
502
|
+
* `actors` key of `createWorldSession`'s config.
|
|
503
|
+
*/
|
|
504
|
+
export function attachRemoteActors(ctx, config) {
|
|
505
|
+
return new RemoteActorStore(ctx, config);
|
|
506
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChunkStore — the SDK-managed chunk/voxel cache: bulk loading, typed
|
|
3
|
+
* per-voxel and per-chunk state, realtime merge of voxel notifications,
|
|
4
|
+
* optimistic local edits, and the deterministic-worldgen write-back pattern.
|
|
5
|
+
* Replaces the WorldStreamer + WorldState + codec plumbing every voxel game
|
|
6
|
+
* hand-writes (~860 LOC in Blocks with Friends).
|
|
7
|
+
*/
|
|
8
|
+
import { type StateCodec } from './codec.js';
|
|
9
|
+
import { type ChunkCoord } from './keys.js';
|
|
10
|
+
import type { WorldSessionContext } from './session.js';
|
|
11
|
+
/** Load lifecycle of a cached chunk. */
|
|
12
|
+
export type ChunkLoadState = 'loading' | 'loaded' | 'missing' | 'seeded' | 'failed';
|
|
13
|
+
/**
|
|
14
|
+
* One cached chunk. Object identity is stable; check `revision` for cheap
|
|
15
|
+
* change detection from a render loop.
|
|
16
|
+
*/
|
|
17
|
+
export interface CachedChunk<TVoxelState = string, TChunkState = string> {
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly coord: ChunkCoord;
|
|
20
|
+
/** Dense voxel-type grid (4096 bytes, `x + y*16 + z*256`), null when unknown. */
|
|
21
|
+
voxels: Uint8Array | null;
|
|
22
|
+
/** Sparse typed per-voxel state by voxel index. */
|
|
23
|
+
voxelStates: Map<number, TVoxelState>;
|
|
24
|
+
/** Typed chunk-level state (null when absent/undecoded). */
|
|
25
|
+
chunkState: TChunkState | null;
|
|
26
|
+
loadState: ChunkLoadState;
|
|
27
|
+
/** Bumped on every change to this chunk. */
|
|
28
|
+
revision: number;
|
|
29
|
+
/** Local time of the last change. */
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
/** Whether sparse voxel states were hydrated (bulk loads omit them). */
|
|
32
|
+
hydrated: boolean;
|
|
33
|
+
/** Whether local edits are queued for write-back. */
|
|
34
|
+
dirty: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Options for {@link attachChunkStore}. */
|
|
37
|
+
export interface ChunkStoreConfig<TVoxelState = string, TChunkState = string> {
|
|
38
|
+
/** Codec for per-voxel state blobs. Defaults to raw base64 strings. */
|
|
39
|
+
voxelStateCodec?: StateCodec<TVoxelState>;
|
|
40
|
+
/** Codec for the chunk-level state blob. Defaults to raw base64 strings. */
|
|
41
|
+
chunkStateCodec?: StateCodec<TChunkState>;
|
|
42
|
+
/**
|
|
43
|
+
* After a bulk load, fetch each chunk individually to hydrate its sparse
|
|
44
|
+
* `voxelStates` (`getChunksByDistance` does NOT return them — a platform
|
|
45
|
+
* trap this store encapsulates). Defaults to true when a
|
|
46
|
+
* `voxelStateCodec` is configured, else false.
|
|
47
|
+
*/
|
|
48
|
+
hydrateVoxelStates?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Called for chunks the server has never stored. Return a 4096-byte dense
|
|
51
|
+
* grid to seed it locally (deterministic client-side worldgen) — seeded
|
|
52
|
+
* chunks are queued for write-back so the world persists and stays
|
|
53
|
+
* identical for everyone.
|
|
54
|
+
*/
|
|
55
|
+
onMissing?: (coord: ChunkCoord) => Uint8Array | undefined | void;
|
|
56
|
+
/**
|
|
57
|
+
* Write-back cadence: one dirty chunk persists per tick (throttled, like
|
|
58
|
+
* the proven BWF pattern). Defaults to 700 ms; `false` disables the timer
|
|
59
|
+
* (call {@link ChunkStore.flush} yourself). Runs on the session ticker.
|
|
60
|
+
*/
|
|
61
|
+
writeBackIntervalMs?: number | false;
|
|
62
|
+
/** Replication radius for outbound voxel updates (0-8). */
|
|
63
|
+
distance?: number;
|
|
64
|
+
/** Decay algorithm for outbound voxel updates (0-5). */
|
|
65
|
+
decayRate?: number;
|
|
66
|
+
/**
|
|
67
|
+
* The actor uuid stamped on outbound voxel updates. Wired from the
|
|
68
|
+
* session's local actor automatically; a random uuid otherwise.
|
|
69
|
+
*/
|
|
70
|
+
actorUuid?: string | (() => string | null);
|
|
71
|
+
/** Clock override for tests. Defaults to `Date.now`. */
|
|
72
|
+
now?: () => number;
|
|
73
|
+
}
|
|
74
|
+
/** A voxel edit for {@link ChunkStore.setVoxel}. */
|
|
75
|
+
export interface SetVoxelInput<TVoxelState> {
|
|
76
|
+
chunk: ChunkCoord;
|
|
77
|
+
/** Within-chunk voxel coordinates (0-15 each). */
|
|
78
|
+
x: number;
|
|
79
|
+
y: number;
|
|
80
|
+
z: number;
|
|
81
|
+
voxelType: number;
|
|
82
|
+
/** Typed per-voxel state (encoded with the store's codec). */
|
|
83
|
+
state?: TVoxelState;
|
|
84
|
+
/** Apply locally before the send resolves. Defaults to true. */
|
|
85
|
+
optimistic?: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The SDK-managed **chunk/voxel cache** — the client-side source of truth
|
|
89
|
+
* for terrain:
|
|
90
|
+
*
|
|
91
|
+
* - `ensureAround(center, radius)` bulk-loads via `chunks.byDistance`
|
|
92
|
+
* (in-flight deduped), hydrates sparse voxel states, marks chunks the
|
|
93
|
+
* server never stored as `missing`, and hands them to your `onMissing`
|
|
94
|
+
* worldgen hook.
|
|
95
|
+
* - Realtime `voxelUpdate` notifications merge into the cache automatically
|
|
96
|
+
* (dense grid write + typed state decode + revision bump + change event).
|
|
97
|
+
* - `setVoxel` applies locally (optimistic) and replicates via the UDP path.
|
|
98
|
+
* - `seed`/`flush` implement deterministic-worldgen write-back through
|
|
99
|
+
* `chunks.update`, one throttled chunk at a time.
|
|
100
|
+
*
|
|
101
|
+
* All reads are synchronous; writes land on WebSocket events, so render
|
|
102
|
+
* loops and background tabs behave (see the module docs).
|
|
103
|
+
*/
|
|
104
|
+
export declare class ChunkStore<TVoxelState = string, TChunkState = string> {
|
|
105
|
+
private readonly ctx;
|
|
106
|
+
private readonly config;
|
|
107
|
+
private readonly chunks;
|
|
108
|
+
private readonly inFlight;
|
|
109
|
+
private readonly writeBackQueue;
|
|
110
|
+
private readonly changeListeners;
|
|
111
|
+
private readonly voxelStateCodec;
|
|
112
|
+
private readonly chunkStateCodec;
|
|
113
|
+
private readonly hydrateStates;
|
|
114
|
+
private readonly now;
|
|
115
|
+
private readonly fallbackUuid;
|
|
116
|
+
private revisionValue;
|
|
117
|
+
private sequence;
|
|
118
|
+
constructor(ctx: WorldSessionContext, config?: ChunkStoreConfig<TVoxelState, TChunkState>);
|
|
119
|
+
/** Bumped on every cache change — poll it cheaply from a render loop. */
|
|
120
|
+
get revision(): number;
|
|
121
|
+
/** The cached chunk at a coordinate (any load state), if tracked. */
|
|
122
|
+
get(coord: ChunkCoord): CachedChunk<TVoxelState, TChunkState> | undefined;
|
|
123
|
+
/** Every tracked chunk (any load state). */
|
|
124
|
+
list(): Array<CachedChunk<TVoxelState, TChunkState>>;
|
|
125
|
+
/** The dense voxel type at a within-chunk coordinate (0 when unknown). */
|
|
126
|
+
voxelTypeAt(coord: ChunkCoord, x: number, y: number, z: number): number;
|
|
127
|
+
/** The typed per-voxel state at a within-chunk coordinate, if any. */
|
|
128
|
+
voxelStateAt(coord: ChunkCoord, x: number, y: number, z: number): TVoxelState | undefined;
|
|
129
|
+
/** Subscribe to per-chunk changes (loads, merges, edits). @returns off. */
|
|
130
|
+
onChunkChanged(listener: (chunk: CachedChunk<TVoxelState, TChunkState>) => void): () => void;
|
|
131
|
+
/**
|
|
132
|
+
* Ensure every chunk within `radius` (Chebyshev, 1-8) of `center` is
|
|
133
|
+
* tracked: bulk-loads untracked ones, hydrates sparse voxel states when
|
|
134
|
+
* configured, marks server-unknown chunks `missing`, and seeds them via
|
|
135
|
+
* `onMissing`. In-flight requests are deduped; safe to call every time the
|
|
136
|
+
* player crosses a chunk boundary.
|
|
137
|
+
*/
|
|
138
|
+
ensureAround(center: ChunkCoord, radius: number): Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* Hydrate one chunk's sparse voxel states (and chunk state) via a
|
|
141
|
+
* single-chunk fetch — bulk loads omit them.
|
|
142
|
+
*/
|
|
143
|
+
hydrate(coord: ChunkCoord): Promise<void>;
|
|
144
|
+
/**
|
|
145
|
+
* Edit one voxel: applies to the cache immediately (optimistic) and
|
|
146
|
+
* replicates via the realtime voxel path. Resolves with the send
|
|
147
|
+
* acceptance.
|
|
148
|
+
*/
|
|
149
|
+
setVoxel(input: SetVoxelInput<TVoxelState>): Promise<boolean>;
|
|
150
|
+
/**
|
|
151
|
+
* Seed a locally generated chunk (deterministic worldgen) and queue it for
|
|
152
|
+
* write-back so the server copy exists for everyone.
|
|
153
|
+
*/
|
|
154
|
+
seed(coord: ChunkCoord, voxels: Uint8Array, options?: {
|
|
155
|
+
writeBack?: boolean;
|
|
156
|
+
}): void;
|
|
157
|
+
/** Queue a tracked chunk's dense grid for (throttled) write-back. */
|
|
158
|
+
markDirty(coord: ChunkCoord): void;
|
|
159
|
+
/** Chunks currently queued for write-back. */
|
|
160
|
+
get pendingWriteBacks(): number;
|
|
161
|
+
/** Persist every queued chunk now (awaits all writes). */
|
|
162
|
+
flush(): Promise<void>;
|
|
163
|
+
/** Drop tracked chunks farther than `radius` from `center` (dirty ones kept). */
|
|
164
|
+
pruneBeyond(center: ChunkCoord, radius: number): void;
|
|
165
|
+
private persistNext;
|
|
166
|
+
private applyServerChunk;
|
|
167
|
+
private markMissing;
|
|
168
|
+
private applyVoxel;
|
|
169
|
+
private ensureEntry;
|
|
170
|
+
private decodeChunkState;
|
|
171
|
+
private touch;
|
|
172
|
+
private senderUuid;
|
|
173
|
+
private nextSequence;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Attach a {@link ChunkStore} to a world session context. Prefer the
|
|
177
|
+
* `chunks` key of `createWorldSession`'s config.
|
|
178
|
+
*/
|
|
179
|
+
export declare function attachChunkStore<TVoxelState = string, TChunkState = string>(ctx: WorldSessionContext, config?: ChunkStoreConfig<TVoxelState, TChunkState>): ChunkStore<TVoxelState, TChunkState>;
|
|
180
|
+
//# sourceMappingURL=chunks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunks.d.ts","sourceRoot":"","sources":["../../src/stores/chunks.ts"],"names":[],"mappings":"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"}
|