@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.
- package/CHANGELOG.md +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- 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>;
|