@net-mesh/browser 0.37.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +666 -0
  5. package/dist/async-queue.d.ts +26 -0
  6. package/dist/async-queue.js +62 -0
  7. package/dist/credential.d.ts +70 -0
  8. package/dist/credential.js +95 -0
  9. package/dist/errors.d.ts +400 -0
  10. package/dist/errors.js +726 -0
  11. package/dist/events.d.ts +232 -0
  12. package/dist/events.js +298 -0
  13. package/dist/identity.d.ts +40 -0
  14. package/dist/identity.js +78 -0
  15. package/dist/index.bundle.js +2 -0
  16. package/dist/index.d.ts +38 -0
  17. package/dist/index.js +46 -0
  18. package/dist/leader/events.d.ts +111 -0
  19. package/dist/leader/events.js +116 -0
  20. package/dist/leader/index.d.ts +14 -0
  21. package/dist/leader/index.js +11 -0
  22. package/dist/leader/session.d.ts +539 -0
  23. package/dist/leader/session.js +770 -0
  24. package/dist/leader/wasm.d.ts +123 -0
  25. package/dist/leader/wasm.js +39 -0
  26. package/dist/lobby.d.ts +183 -0
  27. package/dist/lobby.js +504 -0
  28. package/dist/local.d.ts +80 -0
  29. package/dist/local.js +160 -0
  30. package/dist/net_leaf.d.ts +1494 -0
  31. package/dist/net_leaf.js +3753 -0
  32. package/dist/net_leaf_bg.wasm +0 -0
  33. package/dist/net_leaf_bg.wasm.d.ts +137 -0
  34. package/dist/netcode/client.d.ts +102 -0
  35. package/dist/netcode/client.js +296 -0
  36. package/dist/netcode/clock.d.ts +45 -0
  37. package/dist/netcode/clock.js +54 -0
  38. package/dist/netcode/host.d.ts +118 -0
  39. package/dist/netcode/host.js +276 -0
  40. package/dist/netcode/index.d.ts +19 -0
  41. package/dist/netcode/index.js +14 -0
  42. package/dist/netcode/interpolate.d.ts +50 -0
  43. package/dist/netcode/interpolate.js +100 -0
  44. package/dist/netcode/wire.d.ts +125 -0
  45. package/dist/netcode/wire.js +117 -0
  46. package/dist/node.d.ts +908 -0
  47. package/dist/node.js +1085 -0
  48. package/dist/org.d.ts +412 -0
  49. package/dist/org.js +567 -0
  50. package/dist/peer-driver.d.ts +117 -0
  51. package/dist/peer-driver.js +224 -0
  52. package/dist/store/assembly.d.ts +176 -0
  53. package/dist/store/assembly.js +325 -0
  54. package/dist/store/chunker.d.ts +83 -0
  55. package/dist/store/chunker.js +190 -0
  56. package/dist/store/core.d.ts +136 -0
  57. package/dist/store/core.js +477 -0
  58. package/dist/store/definition.d.ts +23 -0
  59. package/dist/store/definition.js +83 -0
  60. package/dist/store/errors.d.ts +77 -0
  61. package/dist/store/errors.js +94 -0
  62. package/dist/store/host.d.ts +247 -0
  63. package/dist/store/host.js +675 -0
  64. package/dist/store/index.d.ts +50 -0
  65. package/dist/store/index.js +40 -0
  66. package/dist/store/interest.d.ts +40 -0
  67. package/dist/store/interest.js +77 -0
  68. package/dist/store/inventory.d.ts +81 -0
  69. package/dist/store/inventory.js +140 -0
  70. package/dist/store/join.d.ts +118 -0
  71. package/dist/store/join.js +724 -0
  72. package/dist/store/json.d.ts +50 -0
  73. package/dist/store/json.js +283 -0
  74. package/dist/store/ledger.d.ts +187 -0
  75. package/dist/store/ledger.js +292 -0
  76. package/dist/store/owner.d.ts +616 -0
  77. package/dist/store/owner.js +1711 -0
  78. package/dist/store/patch.d.ts +70 -0
  79. package/dist/store/patch.js +166 -0
  80. package/dist/store/player.d.ts +65 -0
  81. package/dist/store/player.js +261 -0
  82. package/dist/store/replica.d.ts +187 -0
  83. package/dist/store/replica.js +740 -0
  84. package/dist/store/state.d.ts +42 -0
  85. package/dist/store/state.js +165 -0
  86. package/dist/store/types.d.ts +200 -0
  87. package/dist/store/types.js +25 -0
  88. package/dist/store/visibility.d.ts +118 -0
  89. package/dist/store/visibility.js +282 -0
  90. package/dist/store/wire.d.ts +309 -0
  91. package/dist/store/wire.js +834 -0
  92. package/dist/stream.d.ts +174 -0
  93. package/dist/stream.js +238 -0
  94. package/dist/three/index.d.ts +109 -0
  95. package/dist/three/index.js +130 -0
  96. package/dist/udp-probe.d.ts +159 -0
  97. package/dist/udp-probe.js +245 -0
  98. package/dist/wasm.d.ts +642 -0
  99. package/dist/wasm.js +98 -0
  100. package/dist/world/border.d.ts +84 -0
  101. package/dist/world/border.js +99 -0
  102. package/dist/world/handoff.d.ts +187 -0
  103. package/dist/world/handoff.js +222 -0
  104. package/dist/world/index.d.ts +13 -0
  105. package/dist/world/index.js +9 -0
  106. package/dist/world/link.d.ts +179 -0
  107. package/dist/world/link.js +476 -0
  108. package/dist/world/regions.d.ts +154 -0
  109. package/dist/world/regions.js +314 -0
  110. package/package.json +72 -0
@@ -0,0 +1,476 @@
1
+ /**
2
+ * Handoff between real region hosts: the messages {@link handoff.ts}'s steps
3
+ * produce, carried over the mesh, and a driver that runs the steps in the
4
+ * order their correctness needs — commit, make durable, THEN send.
5
+ *
6
+ * ```ts
7
+ * const link = handoffLink({ transport: node, label: 'my-world.handoff', peerOf: region => directory[region] });
8
+ * const region = storeRegion(host, { region: 'r:4:7', collection: 'ships', ledger: 'handoff' });
9
+ * const handoffs = regionHandoffs({
10
+ * link, region: 'r:4:7', ...region,
11
+ * persist: () => { saving.flush(); }, // persistStore(host, …): durable before any send
12
+ * onEvent: event => …, // moved / refused / unresolved / admitted
13
+ * });
14
+ * handoffs.handoff('ship-7', 'r:4:8'); // when the ship crosses the border
15
+ * ```
16
+ */
17
+ import { peerHexOf } from '../store/host.js';
18
+ import { ghostTargets, onForwardedAction, pruneActs, } from './border.js';
19
+ import { beginHandoff, locate, onHandoffOffer, onHandoffReply, pruneHandled, reofferHandoff, retryHandoffs, } from './handoff.js';
20
+ const encoder = new TextEncoder();
21
+ const decoder = new TextDecoder('utf-8', { fatal: true });
22
+ function isBody(value) {
23
+ if (typeof value !== 'object' || value === null)
24
+ return false;
25
+ const body = value;
26
+ if (typeof body.from !== 'string')
27
+ return false;
28
+ if (body.k !== 'ghost' && typeof body.id !== 'string')
29
+ return false;
30
+ switch (body.k) {
31
+ case 'offer':
32
+ return typeof body.entity === 'string' && 'state' in body;
33
+ case 'accept':
34
+ return true;
35
+ case 'refuse':
36
+ return typeof body.reason === 'string';
37
+ case 'act':
38
+ return typeof body.name === 'string' && 'input' in body;
39
+ case 'result':
40
+ return body.ok === true ? 'output' in body : body.ok === false && typeof body.reason === 'string';
41
+ case 'ghost':
42
+ return (Number.isSafeInteger(body.seq) &&
43
+ typeof body.entities === 'object' &&
44
+ body.entities !== null &&
45
+ !Array.isArray(body.entities));
46
+ default:
47
+ return false;
48
+ }
49
+ }
50
+ /** Carry handoff messages between region hosts over `transport`. */
51
+ export function handoffLink(options) {
52
+ const { label } = options;
53
+ const streams = new Map();
54
+ const opening = new Map();
55
+ const handlers = new Set();
56
+ const dropped = {};
57
+ const drop = (reason) => {
58
+ dropped[reason] = (dropped[reason] ?? 0) + 1;
59
+ };
60
+ let closed = false;
61
+ const refreshedAt = new Map();
62
+ const refresh = (region) => {
63
+ if (options.refresh === undefined)
64
+ return;
65
+ const at = Date.now();
66
+ if (at - (refreshedAt.get(region) ?? -Infinity) < 1_000)
67
+ return;
68
+ refreshedAt.set(region, at);
69
+ try {
70
+ void Promise.resolve(options.refresh(region)).catch(() => undefined);
71
+ }
72
+ catch {
73
+ // A failing refresh leaves the directory as it was.
74
+ }
75
+ };
76
+ const retire = (peer, stream) => {
77
+ if (streams.get(peer) === stream)
78
+ streams.delete(peer);
79
+ try {
80
+ stream.close();
81
+ }
82
+ catch {
83
+ // Already unusable.
84
+ }
85
+ };
86
+ const deliver = (peer, payload) => {
87
+ const stream = streams.get(peer);
88
+ if (stream !== undefined) {
89
+ try {
90
+ void Promise.resolve(stream.send(payload)).catch(() => retire(peer, stream));
91
+ }
92
+ catch {
93
+ retire(peer, stream);
94
+ }
95
+ return;
96
+ }
97
+ // Open once per peer; a message sent meanwhile is dropped (the
98
+ // protocol re-sends), never queued without bound.
99
+ if (opening.has(peer)) {
100
+ drop('opening');
101
+ return;
102
+ }
103
+ opening.set(peer, Promise.resolve()
104
+ .then(() => options.transport.openStream({ reliability: 'reliable', peer, label }))
105
+ .then(opened => {
106
+ if (closed) {
107
+ opened.close();
108
+ return;
109
+ }
110
+ streams.set(peer, opened);
111
+ try {
112
+ void Promise.resolve(opened.send(payload)).catch(() => retire(peer, opened));
113
+ }
114
+ catch {
115
+ retire(peer, opened);
116
+ }
117
+ })
118
+ .catch(() => drop('open-failed'))
119
+ .finally(() => opening.delete(peer)));
120
+ };
121
+ const stop = options.transport.onEvent(event => {
122
+ if (closed || event.type !== 'stream_data' || event.payload === undefined)
123
+ return;
124
+ let frame;
125
+ try {
126
+ frame = JSON.parse(decoder.decode(event.payload));
127
+ }
128
+ catch {
129
+ return; // not ours
130
+ }
131
+ if (typeof frame !== 'object' || frame === null || frame.n !== label)
132
+ return;
133
+ if (!isBody(frame.b)) {
134
+ drop('malformed');
135
+ return;
136
+ }
137
+ const body = frame.b;
138
+ // A region speaks only from the node that hosts it: a player (or any
139
+ // other node) cannot offer an entity into a region or settle a
140
+ // handoff on another region's behalf.
141
+ const sender = typeof event.peerNode === 'string' ? peerHexOf(event.peerNode) : null;
142
+ const expected = options.peerOf(body.from);
143
+ if (sender === null || expected === null || peerHexOf(expected) !== sender) {
144
+ drop('unauthenticated');
145
+ refresh(body.from);
146
+ return;
147
+ }
148
+ for (const handler of [...handlers])
149
+ handler(body);
150
+ });
151
+ return {
152
+ send(message) {
153
+ if (closed)
154
+ return;
155
+ const peer = options.peerOf(message.to);
156
+ const hex = peer === null ? null : peerHexOf(peer);
157
+ if (hex === null) {
158
+ drop('unknown-region');
159
+ refresh(message.to);
160
+ return;
161
+ }
162
+ deliver(hex, encoder.encode(JSON.stringify({ n: label, b: message.body })));
163
+ },
164
+ onMessage(handler) {
165
+ handlers.add(handler);
166
+ return () => {
167
+ handlers.delete(handler);
168
+ };
169
+ },
170
+ get dropped() {
171
+ return { ...dropped };
172
+ },
173
+ close() {
174
+ if (closed)
175
+ return;
176
+ closed = true;
177
+ stop();
178
+ for (const [peer, stream] of [...streams])
179
+ retire(peer, stream);
180
+ },
181
+ };
182
+ }
183
+ /** Why a forwarded action did not produce an answer. */
184
+ export class BorderActionError extends Error {
185
+ code;
186
+ constructor(code, message) {
187
+ super(message);
188
+ this.code = code;
189
+ this.name = 'BorderActionError';
190
+ }
191
+ }
192
+ function randomId(region) {
193
+ const bytes = new Uint8Array(8);
194
+ globalThis.crypto.getRandomValues(bytes);
195
+ return `${region}:${[...bytes].map(b => b.toString(16).padStart(2, '0')).join('')}`;
196
+ }
197
+ /** Run handoffs for one region host. */
198
+ export function regionHandoffs(options) {
199
+ const now = options.now ?? Date.now;
200
+ const newId = options.newId ?? (() => randomId(options.region));
201
+ let closed = false;
202
+ // Steps run one at a time: each loads the state the previous one
203
+ // committed, and a slow `persist` cannot let a later step's messages
204
+ // overtake an earlier step's durability.
205
+ let queue = Promise.resolve();
206
+ const run = (step, result) => {
207
+ const next = queue.then(async () => {
208
+ const before = options.load();
209
+ const done = step(before);
210
+ if (done.state !== before) {
211
+ options.commit(done.state);
212
+ if (options.persist)
213
+ await options.persist();
214
+ }
215
+ for (const message of done.send)
216
+ options.link.send(message);
217
+ for (const event of done.events)
218
+ options.onEvent?.(event);
219
+ return result();
220
+ });
221
+ queue = next.catch(() => undefined);
222
+ return next;
223
+ };
224
+ // Actions this host forwarded, awaiting an answer. In memory only: see
225
+ // `border.ts` for why that keeps at-most-once.
226
+ const forwarded = new Map();
227
+ // Ghosts: what each neighbour last sent (with when), and what we send.
228
+ const received = new Map();
229
+ const ghostListeners = new Set();
230
+ let ghostSeq = 0;
231
+ let ghostSentAt = -Infinity;
232
+ const ghostEvery = options.ghosting?.everyMs ?? 100;
233
+ const sendGhosts = (at) => {
234
+ const ghosting = options.ghosting;
235
+ if (ghosting === undefined || at - ghostSentAt < ghostEvery)
236
+ return;
237
+ ghostSentAt = at;
238
+ const byNeighbour = new Map();
239
+ // Every neighbour hears from us each round, even with nothing near its
240
+ // border: an empty frame is how ghosts that left get cleared.
241
+ const match = /^r:(-?\d+):(-?\d+)$/.exec(options.region);
242
+ if (match !== null) {
243
+ for (let dx = -1; dx <= 1; dx += 1) {
244
+ for (let dz = -1; dz <= 1; dz += 1) {
245
+ if (dx !== 0 || dz !== 0)
246
+ byNeighbour.set(`r:${Number(match[1]) + dx}:${Number(match[2]) + dz}`, {});
247
+ }
248
+ }
249
+ }
250
+ for (const [id, entity] of Object.entries(options.load().entities)) {
251
+ const at2 = ghosting.positionOf(entity);
252
+ if (at2 === null)
253
+ continue;
254
+ for (const target of ghostTargets(options.region, at2.x, at2.z, ghosting.size, ghosting.margin)) {
255
+ const bucket = byNeighbour.get(target);
256
+ if (bucket !== undefined) {
257
+ Object.defineProperty(bucket, id, { value: entity, enumerable: true, writable: true, configurable: true });
258
+ }
259
+ }
260
+ }
261
+ ghostSeq += 1;
262
+ for (const [to, entities] of byNeighbour) {
263
+ const frame = { k: 'ghost', from: options.region, seq: ghostSeq, entities };
264
+ options.link.send({ to, body: frame });
265
+ }
266
+ };
267
+ const retryMs = options.timing?.retryMs ?? 250;
268
+ const giveUpMs = options.timing?.giveUpMs ?? 60_000;
269
+ const retentionMs = options.timing?.handledRetentionMs ?? 600_000;
270
+ const stop = options.link.onMessage(body => {
271
+ if (closed)
272
+ return;
273
+ switch (body.k) {
274
+ case 'offer':
275
+ void run(state => onHandoffOffer(state, body, now(), options.admit), () => undefined);
276
+ return;
277
+ case 'accept':
278
+ case 'refuse':
279
+ void run(state => onHandoffReply(state, body), () => undefined);
280
+ return;
281
+ case 'act': {
282
+ let reply = null;
283
+ void run(state => {
284
+ const done = onForwardedAction(state, body, now(), options.actions ?? {});
285
+ reply = done.reply;
286
+ return { state: done.state, send: [{ to: body.from, body: done.reply }], events: [] };
287
+ }, () => reply);
288
+ return;
289
+ }
290
+ case 'ghost': {
291
+ const known = received.get(body.from);
292
+ if (known !== undefined && known.seq >= body.seq)
293
+ return; // reordered or repeated
294
+ received.set(body.from, { seq: body.seq, entities: body.entities, at: now() });
295
+ for (const listener of [...ghostListeners])
296
+ listener();
297
+ return;
298
+ }
299
+ case 'result': {
300
+ const pending = forwarded.get(body.id);
301
+ if (pending === undefined || pending.to !== body.from)
302
+ return;
303
+ forwarded.delete(body.id);
304
+ if (body.ok)
305
+ pending.resolve(body.output);
306
+ else
307
+ pending.reject(new BorderActionError('refused', body.reason));
308
+ return;
309
+ }
310
+ }
311
+ });
312
+ const timer = setInterval(() => {
313
+ if (closed)
314
+ return;
315
+ const at = now();
316
+ sendGhosts(at);
317
+ // A neighbour gone quiet: its ghosts are stale, so they go.
318
+ let expired = false;
319
+ for (const [from, frame] of received) {
320
+ if (at - frame.at > ghostEvery * 5) {
321
+ received.delete(from);
322
+ expired = true;
323
+ }
324
+ }
325
+ if (expired)
326
+ for (const listener of [...ghostListeners])
327
+ listener();
328
+ for (const [id, pending] of forwarded) {
329
+ if (at - pending.since >= giveUpMs) {
330
+ forwarded.delete(id);
331
+ pending.reject(new BorderActionError('unresolved', `no answer from ${pending.to} for action ${id}; it may have run, never twice`));
332
+ }
333
+ else if (at - pending.sentAt >= retryMs) {
334
+ pending.sentAt = at;
335
+ options.link.send({ to: pending.to, body: pending.body });
336
+ }
337
+ }
338
+ void run(state => {
339
+ const retried = retryHandoffs(state, now(), options.timing);
340
+ const pruned = pruneActs(pruneHandled(retried.state, now(), options.timing), now(), retentionMs);
341
+ return { ...retried, state: pruned };
342
+ }, () => undefined);
343
+ }, options.tickMs ?? 100);
344
+ timer.unref?.();
345
+ return {
346
+ handoff(entity, to) {
347
+ const id = newId();
348
+ return run(state => beginHandoff(state, entity, to, id, now()), () => id);
349
+ },
350
+ locate: entity => locate(options.load(), entity),
351
+ reoffer: id => run(state => reofferHandoff(state, id, now()), () => undefined),
352
+ ghosts() {
353
+ const out = {};
354
+ for (const frame of received.values()) {
355
+ for (const [id, entity] of Object.entries(frame.entities)) {
356
+ Object.defineProperty(out, id, { value: entity, enumerable: true, writable: true, configurable: true });
357
+ }
358
+ }
359
+ return out;
360
+ },
361
+ onGhosts(listener) {
362
+ ghostListeners.add(listener);
363
+ return () => {
364
+ ghostListeners.delete(listener);
365
+ };
366
+ },
367
+ forward(to, name, input) {
368
+ if (closed)
369
+ return Promise.reject(new BorderActionError('unresolved', 'the handoff driver is closed'));
370
+ const id = newId();
371
+ const body = { k: 'act', id, from: options.region, name, input };
372
+ return new Promise((resolve, reject) => {
373
+ const at = now();
374
+ forwarded.set(id, { body, to, since: at, sentAt: at, resolve, reject });
375
+ options.link.send({ to, body });
376
+ });
377
+ },
378
+ close() {
379
+ if (closed)
380
+ return;
381
+ closed = true;
382
+ clearInterval(timer);
383
+ stop();
384
+ for (const [id, pending] of forwarded) {
385
+ forwarded.delete(id);
386
+ pending.reject(new BorderActionError('unresolved', 'the handoff driver closed before an answer'));
387
+ }
388
+ },
389
+ };
390
+ }
391
+ /**
392
+ * A region whose entities are a hosted store's collection, with the handoff
393
+ * ledger under another top-level key of the SAME document — so the store's
394
+ * persistence (`persistStore`) makes both durable as one unit.
395
+ *
396
+ * The definition must accept the ledger key (an object of `outgoing` and
397
+ * `handled` records; {@link parseHandoffLedger} validates one) and should
398
+ * hide it from players (`visibility: { [ledger]: 'nobody' }`).
399
+ */
400
+ export function storeRegion(host, options) {
401
+ return {
402
+ load: () => {
403
+ const doc = host.getState();
404
+ const entities = (doc[options.collection] ?? {});
405
+ const ledger = (doc[options.ledger] ?? { outgoing: {}, handled: {} });
406
+ return {
407
+ region: options.region,
408
+ entities,
409
+ outgoing: ledger.outgoing,
410
+ handled: ledger.handled,
411
+ acts: ledger.acts ?? {},
412
+ };
413
+ },
414
+ commit: next => {
415
+ const doc = host.getState();
416
+ host.setState({
417
+ ...doc,
418
+ [options.collection]: next.entities,
419
+ [options.ledger]: { outgoing: next.outgoing, handled: next.handled, acts: next.acts ?? {} },
420
+ });
421
+ },
422
+ };
423
+ }
424
+ /** Validate a handoff ledger read from a store document; `parseEntity` validates the frozen entities. */
425
+ export function parseHandoffLedger(value, parseEntity) {
426
+ const record = (v, what) => {
427
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
428
+ throw new Error(`${what} is not a record`);
429
+ return v;
430
+ };
431
+ // An id is data from another host: `__proto__` must stay a key.
432
+ const define = (target, key, entry) => {
433
+ Object.defineProperty(target, key, { value: entry, enumerable: true, writable: true, configurable: true });
434
+ };
435
+ if (value === undefined)
436
+ return { outgoing: {}, handled: {} };
437
+ const ledger = record(value, 'the handoff ledger');
438
+ const outgoing = {};
439
+ for (const [id, raw] of Object.entries(record(ledger.outgoing ?? {}, 'outgoing'))) {
440
+ const o = record(raw, `outgoing ${id}`);
441
+ if (typeof o.entity !== 'string' || typeof o.to !== 'string')
442
+ throw new Error(`outgoing ${id} is malformed`);
443
+ define(outgoing, id, {
444
+ entity: o.entity,
445
+ state: parseEntity(o.state),
446
+ to: o.to,
447
+ since: Number(o.since),
448
+ sentAt: Number(o.sentAt),
449
+ unresolved: o.unresolved === true,
450
+ });
451
+ }
452
+ const handled = {};
453
+ for (const [id, raw] of Object.entries(record(ledger.handled ?? {}, 'handled'))) {
454
+ const h = record(raw, `handled ${id}`);
455
+ if (h.decision !== 'accept' && h.decision !== 'refuse')
456
+ throw new Error(`handled ${id} is malformed`);
457
+ define(handled, id, {
458
+ decision: h.decision,
459
+ at: Number(h.at),
460
+ ...(typeof h.reason === 'string' ? { reason: h.reason } : {}),
461
+ });
462
+ }
463
+ const acts = {};
464
+ for (const [id, raw] of Object.entries(record(ledger.acts ?? {}, 'acts'))) {
465
+ const a = record(raw, `act ${id}`);
466
+ if (typeof a.ok !== 'boolean')
467
+ throw new Error(`act ${id} is malformed`);
468
+ define(acts, id, {
469
+ at: Number(a.at),
470
+ ok: a.ok,
471
+ ...('output' in a ? { output: a.output } : {}),
472
+ ...(typeof a.reason === 'string' ? { reason: a.reason } : {}),
473
+ });
474
+ }
475
+ return { outgoing, handled, acts };
476
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Large worlds: regions, their directory, and a player's merged view
3
+ * (browser plan §9 items 1–2).
4
+ *
5
+ * The map is cut into square regions of `size` units; region `r:<rx>:<rz>`
6
+ * covers `rx*size <= x < (rx+1)*size`, the same for z. Each region is its
7
+ * own store, hosted by whichever node announces it:
8
+ *
9
+ * ```ts
10
+ * // A region host (usually a dedicated Node host):
11
+ * hostStore({ definition: world, store: 'r:4:7', transport, … });
12
+ * announceRegions(node, 'my-world', ['r:4:7', 'r:4:8']);
13
+ *
14
+ * // A player:
15
+ * const view = joinWorld({ node, world: 'my-world', definition: world, collection: 'ships',
16
+ * position: { x, z }, size: 256, key: 'player', maxEventBytes: 8104 });
17
+ * bindEntities({ store: view, select: entities => entities, … });
18
+ * view.setPosition(x, z); // as the player moves
19
+ * await view.act('fire', { … }); // to the region the player is in
20
+ * ```
21
+ *
22
+ * **The directory is discovery, not a root of trust.** Any node can
23
+ * announce any tag. {@link regionDirectory} therefore takes `trustedHosts`,
24
+ * the node ids that may host this world's regions. Without that list, a
25
+ * region two nodes claim is `ambiguous` and is NOT joined, rather than
26
+ * joining whichever answered first. A handoff link that authenticates by this
27
+ * directory (`handoffLink({ peerOf: directory.peerOf })`) needs the list:
28
+ * otherwise a node that announces a region could also send offers as it.
29
+ */
30
+ import type { NodeDescriptor } from '../node.js';
31
+ import type { StoreTransport } from '../store/host.js';
32
+ import type { ActionSpec, Cancel, InputSpec, StoreDefinition } from '../store/types.js';
33
+ /** A node that can join stores and use the directory. */
34
+ export interface WorldNode extends StoreTransport {
35
+ announce(capabilities: readonly string[]): Promise<void>;
36
+ query(capability: string): Promise<NodeDescriptor[]>;
37
+ }
38
+ /** Region `r:<rx>:<rz>` for a position, with regions `size` units square. */
39
+ export declare function regionOf(x: number, z: number, size: number): string;
40
+ /** The regions within `radius` regions of a position (a square, `(2r+1)²`). */
41
+ export declare function regionsAround(x: number, z: number, options: {
42
+ readonly size: number;
43
+ readonly radius?: number;
44
+ }): string[];
45
+ /** The directory tag a region host announces. */
46
+ export declare function regionTag(world: string, region: string): string;
47
+ /** How often region hosts re-announce: an announcement is a lease. */
48
+ export declare const REGION_ANNOUNCE_MS = 2000;
49
+ /**
50
+ * Announce that this node hosts `regions` of `world`, and keep announcing
51
+ * (an announcement is a lease). `announce` replaces a node's tags, so pass
52
+ * any other tags this node carries in `tags`. Returns a stop function.
53
+ */
54
+ export declare function announceRegions(node: Pick<WorldNode, 'announce'>, world: string, regions: readonly string[], options?: {
55
+ readonly tags?: readonly string[];
56
+ readonly everyMs?: number;
57
+ }): Cancel;
58
+ /** What the directory knows about a region. */
59
+ export type RegionLookup = {
60
+ readonly status: 'hosted';
61
+ readonly host: string;
62
+ } | {
63
+ readonly status: 'unhosted';
64
+ } | {
65
+ readonly status: 'ambiguous';
66
+ readonly hosts: readonly string[];
67
+ };
68
+ /** A cached view of which node hosts which region. */
69
+ export interface RegionDirectory {
70
+ /** Ask the mesh now (and cache the answer). */
71
+ lookup(region: string): Promise<RegionLookup>;
72
+ /** The cached host of a region, 16 hex, or `null` — synchronous, for `handoffLink`'s `peerOf`. */
73
+ peerOf(region: string): string | null;
74
+ }
75
+ /** {@link regionDirectory}'s argument. */
76
+ export interface RegionDirectoryOptions {
77
+ readonly node: Pick<WorldNode, 'query'>;
78
+ readonly world: string;
79
+ /**
80
+ * The node ids (16 hex) that may host this world's regions. Announcements
81
+ * from any other node are ignored. Omit, and a region more than one node
82
+ * claims is `ambiguous`.
83
+ */
84
+ readonly trustedHosts?: readonly string[];
85
+ }
86
+ /** A region directory over the mesh's signed announcements. */
87
+ export declare function regionDirectory(options: RegionDirectoryOptions): RegionDirectory;
88
+ /** One region of a player's world view. */
89
+ export interface WorldRegion {
90
+ readonly region: string;
91
+ /** `looking` (directory lookup pending), `unhosted`, `ambiguous`, `joining`, `ready`, or `failed`. */
92
+ readonly phase: 'looking' | 'unhosted' | 'ambiguous' | 'joining' | 'ready' | 'failed';
93
+ readonly host: string | null;
94
+ }
95
+ /** {@link joinWorld}'s argument. */
96
+ export interface JoinWorldOptions<S extends object, A extends ActionSpec, I extends InputSpec> {
97
+ readonly node: WorldNode;
98
+ readonly world: string;
99
+ /** Every region's store definition (one definition, one store per region). */
100
+ readonly definition: StoreDefinition<S, A, I>;
101
+ /** The top-level entity map the view merges across regions. */
102
+ readonly collection: string;
103
+ readonly position: {
104
+ readonly x: number;
105
+ readonly z: number;
106
+ };
107
+ /** Region size, the same the hosts use. */
108
+ readonly size: number;
109
+ /** Regions kept around the player's. Default 1 (a 3×3 block). Regions beyond `radius + 1` are released. */
110
+ readonly radius?: number;
111
+ readonly key: string;
112
+ readonly audience?: readonly string[];
113
+ readonly maxEventBytes: number;
114
+ /** The directory to use; default one over `node` (with `trustedHosts`). */
115
+ readonly directory?: RegionDirectory;
116
+ readonly trustedHosts?: readonly string[];
117
+ /**
118
+ * An entity's position, to break a tie when it shows in two regions at
119
+ * once (a handoff's source and destination replicas can briefly both hold
120
+ * it): the copy from the region containing it wins. Default: the first
121
+ * region's copy.
122
+ */
123
+ readonly positionOf?: (entity: unknown) => {
124
+ readonly x: number;
125
+ readonly z: number;
126
+ } | null;
127
+ /** Retry an unhosted or failed region this often, ms. Default 2000. */
128
+ readonly retryMs?: number;
129
+ /**
130
+ * How long an entity that vanishes from a region the view still holds
131
+ * stays in the view at its last state, ms. Default 500. A handoff freezes
132
+ * an entity at its source before the destination's replica shows it, and
133
+ * without this it would blink out at the border. It stops lingering as
134
+ * soon as it appears in any region. `0` disables. Entities of a region
135
+ * released because the player moved away go at once.
136
+ */
137
+ readonly lingerMs?: number;
138
+ }
139
+ /** A player's view of a world across regions. */
140
+ export interface WorldView<E, A extends ActionSpec> {
141
+ /** Every entity in the regions held, merged by id. Satisfies `bindEntities`' store. */
142
+ getState(): Readonly<Record<string, E>>;
143
+ subscribe(listener: (entities: Readonly<Record<string, E>>, previous: Readonly<Record<string, E>>) => void): Cancel;
144
+ /** Move: regions come into and go out of the view. */
145
+ setPosition(x: number, z: number): void;
146
+ /** The region the player is in. */
147
+ currentRegion(): string;
148
+ regions(): readonly WorldRegion[];
149
+ /** An action, sent to the region the player is in. Rejects if that region is not ready. */
150
+ act<K extends keyof A & string>(name: K, input: A[K]['input']): Promise<A[K]['output']>;
151
+ close(): Promise<void>;
152
+ }
153
+ /** Join a world: the player's region and its neighbours, merged into one view. */
154
+ export declare function joinWorld<S extends object, A extends ActionSpec, I extends InputSpec, E = unknown>(options: JoinWorldOptions<S, A, I>): WorldView<E, A>;