@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,222 @@
1
+ /**
2
+ * Entity handoff between region hosts — at-most-once, with a typed failure
3
+ * (browser plan §9 item 3, decision Q6).
4
+ *
5
+ * An entity crossing from region A to region B must never be live in both,
6
+ * and must never be applied twice. Exactly-once is not attempted; the
7
+ * failure a crash can cause is an entity that is **nowhere live for a
8
+ * while**, reported typed, never a duplicate.
9
+ *
10
+ * The protocol, over any lossy, duplicating, reordering carrier:
11
+ *
12
+ * 1. **A freezes.** The entity leaves A's live entities and waits in A's
13
+ * `outgoing` under a fresh handoff id, with its state. A answers inputs for
14
+ * it with `in-transit` (see {@link locate}).
15
+ * 2. **A offers** `{ id, entity, state }` to B, and repeats the offer until it
16
+ * hears back. The id is the idempotence key.
17
+ * 3. **B decides once per id.** On the first offer it either admits the
18
+ * entity (it is now live in B) or refuses it, and records the decision in
19
+ * `handled`. Every later copy of that offer gets the SAME answer and
20
+ * changes nothing.
21
+ * 4. **A settles.** `accept`: the frozen copy is dropped, and the entity is
22
+ * B's. `refuse`: the entity returns to A's live entities. Only an explicit
23
+ * refusal can bring it back to A, because silence proves nothing: B may
24
+ * have admitted it and the answer been lost.
25
+ * 5. **No answer.** A keeps it frozen and keeps offering. After `giveUpMs`,
26
+ * {@link retryHandoffs} reports it `unresolved` and stops offering. The
27
+ * entity stays frozen, still nowhere live twice. Recovery (the destination
28
+ * returning, an operator) re-offers with the same id through
29
+ * {@link reofferHandoff}, which B answers from its record.
30
+ *
31
+ * **Durability is the caller's, and it is the whole correctness argument.**
32
+ * Every step here is a pure function from one {@link RegionState} to the
33
+ * next. The host MUST make the returned state durable (e.g.
34
+ * `persistStore(…).flush()`) BEFORE it sends the messages the step returns. A
35
+ * crash then either loses the step with its messages, which is the same as a
36
+ * lost message, or keeps both. B must also remember a decision for longer
37
+ * than any A keeps offering: `handledRetentionMs` > `giveUpMs`.
38
+ */
39
+ /** An empty region. */
40
+ export function regionState(region, entities = {}) {
41
+ return { region, entities, outgoing: {}, handled: {} };
42
+ }
43
+ function timing(options = {}) {
44
+ const retryMs = options.retryMs ?? 250;
45
+ const giveUpMs = options.giveUpMs ?? 60_000;
46
+ const handledRetentionMs = options.handledRetentionMs ?? 600_000;
47
+ if (!(handledRetentionMs > giveUpMs)) {
48
+ // A target that forgot a decision would admit a late re-offer a second
49
+ // time: the one way this protocol could duplicate.
50
+ throw new RangeError('handledRetentionMs must exceed giveUpMs, or a late offer could be admitted twice');
51
+ }
52
+ return { retryMs, giveUpMs, handledRetentionMs };
53
+ }
54
+ const has = (record, key) => Object.prototype.hasOwnProperty.call(record, key);
55
+ function without(record, key) {
56
+ const out = {};
57
+ for (const k of Object.keys(record))
58
+ if (k !== key)
59
+ Object.defineProperty(out, k, { value: record[k], enumerable: true, writable: true, configurable: true });
60
+ return out;
61
+ }
62
+ function withEntry(record, key, value) {
63
+ const out = { ...record };
64
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
65
+ return out;
66
+ }
67
+ /**
68
+ * Step 1–2 at the source: freeze `entity` and offer it to region `to`.
69
+ * Throws if the entity is not live here (frozen already, or unknown).
70
+ */
71
+ export function beginHandoff(state, entity, to, id, now) {
72
+ if (!has(state.entities, entity))
73
+ throw new Error(`entity '${entity}' is not live in region '${state.region}'`);
74
+ if (has(state.outgoing, id) || has(state.handled, id))
75
+ throw new Error(`handoff id '${id}' is already in use`);
76
+ if (to === state.region)
77
+ throw new Error('a handoff needs another region');
78
+ const frozen = state.entities[entity];
79
+ const outgoing = { entity, state: frozen, to, since: now, sentAt: now, unresolved: false };
80
+ return {
81
+ state: { ...state, entities: without(state.entities, entity), outgoing: withEntry(state.outgoing, id, outgoing) },
82
+ send: [{ to, body: { k: 'offer', id, from: state.region, entity, state: frozen } }],
83
+ events: [],
84
+ };
85
+ }
86
+ /**
87
+ * Step 3 at the target: decide an offer once, and answer every copy of it
88
+ * the same way. `admit` is asked only the first time; it returns `true` or a
89
+ * refusal reason. An admitted entity whose id is already live here is
90
+ * refused (`occupied`), never overwritten.
91
+ */
92
+ export function onHandoffOffer(state, offer, now, admit = () => true) {
93
+ const previous = has(state.handled, offer.id) ? state.handled[offer.id] : undefined;
94
+ if (previous !== undefined) {
95
+ return { state, send: [{ to: offer.from, body: replyOf(state.region, offer.id, previous) }], events: [] };
96
+ }
97
+ let decision;
98
+ if (has(state.entities, offer.entity)) {
99
+ decision = { decision: 'refuse', reason: 'occupied', at: now };
100
+ }
101
+ else {
102
+ let verdict;
103
+ try {
104
+ verdict = admit(offer.entity, offer.state);
105
+ }
106
+ catch (error) {
107
+ verdict = `admit threw: ${String(error)}`;
108
+ }
109
+ decision = verdict === true ? { decision: 'accept', at: now } : { decision: 'refuse', reason: verdict, at: now };
110
+ }
111
+ const entities = decision.decision === 'accept' ? withEntry(state.entities, offer.entity, offer.state) : state.entities;
112
+ return {
113
+ state: { ...state, entities, handled: withEntry(state.handled, offer.id, decision) },
114
+ send: [{ to: offer.from, body: replyOf(state.region, offer.id, decision) }],
115
+ events: decision.decision === 'accept' ? [{ type: 'admitted', id: offer.id, entity: offer.entity, from: offer.from }] : [],
116
+ };
117
+ }
118
+ function replyOf(region, id, handled) {
119
+ return handled.decision === 'accept'
120
+ ? { k: 'accept', id, from: region }
121
+ : { k: 'refuse', id, from: region, reason: handled.reason ?? 'refused' };
122
+ }
123
+ /**
124
+ * Step 4 at the source: settle on the target's answer. A reply for a handoff
125
+ * this region is not holding (already settled, or never begun) changes
126
+ * nothing: replies are duplicated and reordered like everything else.
127
+ */
128
+ export function onHandoffReply(state, reply) {
129
+ if (!has(state.outgoing, reply.id))
130
+ return { state, send: [], events: [] };
131
+ const outgoing = state.outgoing[reply.id];
132
+ if (reply.from !== outgoing.to)
133
+ return { state, send: [], events: [] };
134
+ const rest = without(state.outgoing, reply.id);
135
+ if (reply.k === 'accept') {
136
+ return {
137
+ state: { ...state, outgoing: rest },
138
+ send: [],
139
+ events: [{ type: 'moved', id: reply.id, entity: outgoing.entity, to: outgoing.to }],
140
+ };
141
+ }
142
+ // Only an explicit refusal returns it. An entity that went live here again
143
+ // in the meantime (a restore, a new spawn) is kept, and the frozen copy is
144
+ // discarded rather than overwriting it.
145
+ const entities = has(state.entities, outgoing.entity)
146
+ ? state.entities
147
+ : withEntry(state.entities, outgoing.entity, outgoing.state);
148
+ return {
149
+ state: { ...state, entities, outgoing: rest },
150
+ send: [],
151
+ events: [{ type: 'refused', id: reply.id, entity: outgoing.entity, to: outgoing.to, reason: reply.reason }],
152
+ };
153
+ }
154
+ /**
155
+ * Step 2 and 5 at the source, on a timer: re-offer what is unanswered, and
156
+ * mark what has waited past `giveUpMs` as `unresolved` (reported once, still
157
+ * frozen, no longer offered).
158
+ */
159
+ export function retryHandoffs(state, now, options) {
160
+ const { retryMs, giveUpMs } = timing(options);
161
+ let outgoing = null;
162
+ const send = [];
163
+ const events = [];
164
+ for (const [id, pending] of Object.entries(state.outgoing)) {
165
+ if (pending.unresolved)
166
+ continue;
167
+ if (now - pending.since >= giveUpMs) {
168
+ outgoing ??= { ...state.outgoing };
169
+ outgoing[id] = { ...pending, unresolved: true };
170
+ events.push({ type: 'unresolved', id, entity: pending.entity, to: pending.to });
171
+ continue;
172
+ }
173
+ if (now - pending.sentAt >= retryMs) {
174
+ outgoing ??= { ...state.outgoing };
175
+ outgoing[id] = { ...pending, sentAt: now };
176
+ send.push({ to: pending.to, body: { k: 'offer', id, from: state.region, entity: pending.entity, state: pending.state } });
177
+ }
178
+ }
179
+ return { state: outgoing === null ? state : { ...state, outgoing }, send, events };
180
+ }
181
+ /**
182
+ * Recovery: offer an `unresolved` handoff again, under the SAME id (the
183
+ * destination answers it from its record, so this cannot admit twice), and
184
+ * restart its give-up clock. Use it when the destination is known to be
185
+ * back. A no-op for a handoff that is not unresolved.
186
+ */
187
+ export function reofferHandoff(state, id, now) {
188
+ if (!has(state.outgoing, id) || !state.outgoing[id].unresolved)
189
+ return { state, send: [], events: [] };
190
+ const pending = state.outgoing[id];
191
+ return {
192
+ state: { ...state, outgoing: withEntry(state.outgoing, id, { ...pending, unresolved: false, since: now, sentAt: now }) },
193
+ send: [{ to: pending.to, body: { k: 'offer', id, from: state.region, entity: pending.entity, state: pending.state } }],
194
+ events: [],
195
+ };
196
+ }
197
+ /** Forget target decisions older than `handledRetentionMs` (which must outlast any source's offers). */
198
+ export function pruneHandled(state, now, options) {
199
+ const { handledRetentionMs } = timing(options);
200
+ let handled = null;
201
+ for (const [id, decision] of Object.entries(state.handled)) {
202
+ if (now - decision.at > handledRetentionMs) {
203
+ handled ??= { ...state.handled };
204
+ delete handled[id];
205
+ }
206
+ }
207
+ return handled === null ? state : { ...state, handled };
208
+ }
209
+ /**
210
+ * Where an entity is, from this region's point of view — what to answer an
211
+ * input for it: `here` (apply it), `in-transit` (frozen, on its way to
212
+ * `to`; refuse, never apply), or `unknown`.
213
+ */
214
+ export function locate(state, entity) {
215
+ if (has(state.entities, entity))
216
+ return { at: 'here' };
217
+ for (const [id, pending] of Object.entries(state.outgoing)) {
218
+ if (pending.entity === entity)
219
+ return { at: 'in-transit', to: pending.to, id };
220
+ }
221
+ return { at: 'unknown' };
222
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@net-mesh/browser/world` — large worlds across region hosts: regions and
3
+ * their directory, a player's merged view (`joinWorld`), at-most-once entity
4
+ * handoff between region hosts, cross-border actions, and ghosting.
5
+ */
6
+ export { beginHandoff, locate, onHandoffOffer, onHandoffReply, pruneHandled, regionState, reofferHandoff, retryHandoffs, } from './handoff.js';
7
+ export type { Handled, HandoffEvent, HandoffId, HandoffMessage, HandoffOffer, HandoffReply, HandoffStep, HandoffTiming, Outgoing, RegionState, } from './handoff.js';
8
+ export { BorderActionError, handoffLink, parseHandoffLedger, regionHandoffs, storeRegion } from './link.js';
9
+ export { ghostTargets, onForwardedAction, pruneActs } from './border.js';
10
+ export type { ActRecord, ActionResult, BorderAction, ForwardedAction, GhostFrame } from './border.js';
11
+ export type { HandoffLedger, HandoffLink, HandoffLinkOptions, HandoffTransport, LinkBody, RegionHandoffs, RegionHandoffsOptions, } from './link.js';
12
+ export { REGION_ANNOUNCE_MS, announceRegions, joinWorld, regionDirectory, regionOf, regionTag, regionsAround, } from './regions.js';
13
+ export type { JoinWorldOptions, RegionDirectory, RegionDirectoryOptions, RegionLookup, WorldNode, WorldRegion, WorldView, } from './regions.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@net-mesh/browser/world` — large worlds across region hosts: regions and
3
+ * their directory, a player's merged view (`joinWorld`), at-most-once entity
4
+ * handoff between region hosts, cross-border actions, and ghosting.
5
+ */
6
+ export { beginHandoff, locate, onHandoffOffer, onHandoffReply, pruneHandled, regionState, reofferHandoff, retryHandoffs, } from './handoff.js';
7
+ export { BorderActionError, handoffLink, parseHandoffLedger, regionHandoffs, storeRegion } from './link.js';
8
+ export { ghostTargets, onForwardedAction, pruneActs } from './border.js';
9
+ export { REGION_ANNOUNCE_MS, announceRegions, joinWorld, regionDirectory, regionOf, regionTag, regionsAround, } from './regions.js';
@@ -0,0 +1,179 @@
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 { type ActRecord, type ActionResult, type BorderAction, type ForwardedAction, type GhostFrame } from './border.js';
18
+ import { type HandoffEvent, type HandoffId, type HandoffMessage, type HandoffOffer, type HandoffReply, type HandoffTiming, type Outgoing, type Handled, type RegionState, locate } from './handoff.js';
19
+ /** The structural transport a link uses: `BrowserNode`, a local-mesh node, `meshStoreTransport`. */
20
+ export interface HandoffTransport {
21
+ openStream(options: {
22
+ reliability: 'reliable' | 'fireAndForget';
23
+ peer?: string;
24
+ label?: string;
25
+ }): {
26
+ send(payload: Uint8Array): unknown;
27
+ close(): unknown;
28
+ } | Promise<{
29
+ send(payload: Uint8Array): unknown;
30
+ close(): unknown;
31
+ }>;
32
+ onEvent(handler: (event: {
33
+ readonly type: string;
34
+ readonly peerNode?: string | null;
35
+ readonly payload?: Uint8Array;
36
+ }) => void): () => void;
37
+ }
38
+ /** {@link handoffLink}'s argument. */
39
+ export interface HandoffLinkOptions {
40
+ readonly transport: HandoffTransport;
41
+ /** Names the world's handoff channel; every region host uses the same one. */
42
+ readonly label: string;
43
+ /**
44
+ * The node (16 hex) hosting `region`, or `null` if unknown — the region
45
+ * directory (e.g. from `query`). Also the AUTHENTICATION: a message that
46
+ * says it comes from region X is accepted only from `peerOf(X)`.
47
+ */
48
+ peerOf(region: string): string | null;
49
+ /**
50
+ * Refresh what `peerOf` knows about a region (e.g. `directory.lookup`).
51
+ * Called, at most once a second per region, when a message is for a region
52
+ * `peerOf` cannot place, or from a sender `peerOf` does not name as that
53
+ * region's host (the directory may be stale). The message itself is still
54
+ * dropped; the protocol's retry finds the refreshed answer.
55
+ */
56
+ refresh?(region: string): unknown;
57
+ }
58
+ /** The running link. */
59
+ export interface HandoffLink<E> {
60
+ /** Send one step's message to the host of `message.to`. Best effort: the protocol retries. */
61
+ send(message: HandoffMessage<E>): void;
62
+ /** Receive authenticated offers, replies, forwarded actions and their results. */
63
+ onMessage(handler: (body: LinkBody<E>) => void): () => void;
64
+ /** Messages refused, by reason. */
65
+ readonly dropped: Readonly<Record<string, number>>;
66
+ close(): void;
67
+ }
68
+ /** Everything a link carries. */
69
+ export type LinkBody<E> = HandoffOffer<E> | HandoffReply | ForwardedAction | ActionResult | GhostFrame<E>;
70
+ /** Carry handoff messages between region hosts over `transport`. */
71
+ export declare function handoffLink<E>(options: HandoffLinkOptions): HandoffLink<E>;
72
+ /** {@link regionHandoffs}'s argument. */
73
+ export interface RegionHandoffsOptions<E> {
74
+ readonly link: HandoffLink<E>;
75
+ /** This host's region. */
76
+ readonly region: string;
77
+ /** The region's current state (entities + ledger). */
78
+ load(): RegionState<E>;
79
+ /** Make `next` the region's state (e.g. commit it to the store). */
80
+ commit(next: RegionState<E>): void;
81
+ /**
82
+ * Make the committed state DURABLE. Awaited after every change and before
83
+ * that step's messages are sent — the protocol's one requirement. Omit
84
+ * only for a world that need not survive a host restart.
85
+ */
86
+ persist?(): void | Promise<void>;
87
+ /** Asked once per incoming entity: `true` to admit, or a refusal reason. */
88
+ admit?(entity: string, state: E): true | string;
89
+ /**
90
+ * Cross-border actions this region serves, by name: applied ONCE per
91
+ * action id, their effect and record committed and made durable before
92
+ * the answer goes back.
93
+ */
94
+ readonly actions?: Readonly<Record<string, BorderAction<E>>>;
95
+ /**
96
+ * Ghosting (plan §9 item 5): send each neighbouring region this region's
97
+ * entities within `margin` of their shared border, read-only, every
98
+ * `everyMs` (default 100). Regions are `r:<rx>:<rz>` squares of `size`.
99
+ * What neighbours send here is read with {@link RegionHandoffs.ghosts}.
100
+ */
101
+ readonly ghosting?: {
102
+ readonly size: number;
103
+ readonly margin: number;
104
+ positionOf(entity: E): {
105
+ readonly x: number;
106
+ readonly z: number;
107
+ } | null;
108
+ readonly everyMs?: number;
109
+ };
110
+ /** Outcomes: `moved`, `refused`, `unresolved` (source), `admitted` (target). */
111
+ onEvent?(event: HandoffEvent): void;
112
+ readonly timing?: HandoffTiming;
113
+ /** How often to re-offer and prune, ms. Default 100. */
114
+ readonly tickMs?: number;
115
+ readonly now?: () => number;
116
+ /** Handoff id source. Default: region plus 64 random bits. */
117
+ readonly newId?: () => HandoffId;
118
+ }
119
+ /** The running handoff driver. */
120
+ export interface RegionHandoffs<E = unknown> {
121
+ /**
122
+ * Hand `entity` to region `to`: frozen here now, live there once it
123
+ * accepts. Resolves with the handoff id after the freeze is durable and the
124
+ * offer sent. Rejects if the entity is not live here.
125
+ */
126
+ handoff(entity: string, to: string): Promise<HandoffId>;
127
+ /** Where an entity is from this region's view: `here`, `in-transit`, `unknown`. */
128
+ locate(entity: string): ReturnType<typeof locate>;
129
+ /** Re-offer an `unresolved` handoff under its own id (the destination is back). */
130
+ reoffer(id: HandoffId): Promise<void>;
131
+ /**
132
+ * Ask region `to` to run action `name` on something it owns; resolves
133
+ * with its answer. Rejects with {@link BorderActionError}: `refused` (it
134
+ * decided no — the reason), or `unresolved` (no answer within `giveUpMs`:
135
+ * it may have happened, never twice).
136
+ */
137
+ forward(to: string, name: string, input: unknown): Promise<unknown>;
138
+ /**
139
+ * Neighbours' entities near this region's borders, read-only — the latest
140
+ * each neighbour sent, until it goes quiet. Not authoritative here: act on
141
+ * them with {@link forward}.
142
+ */
143
+ ghosts(): Readonly<Record<string, E>>;
144
+ /** Called when a neighbour's ghosts change. */
145
+ onGhosts(listener: () => void): () => void;
146
+ close(): void;
147
+ }
148
+ /** Why a forwarded action did not produce an answer. */
149
+ export declare class BorderActionError extends Error {
150
+ readonly code: 'refused' | 'unresolved';
151
+ constructor(code: 'refused' | 'unresolved', message: string);
152
+ }
153
+ /** Run handoffs for one region host. */
154
+ export declare function regionHandoffs<E>(options: RegionHandoffsOptions<E>): RegionHandoffs<E>;
155
+ /** Where {@link storeRegion} keeps the handoff ledger in the document. */
156
+ export interface HandoffLedger<E> {
157
+ readonly outgoing: Readonly<Record<HandoffId, Outgoing<E>>>;
158
+ readonly handled: Readonly<Record<HandoffId, Handled>>;
159
+ readonly acts?: Readonly<Record<string, ActRecord>>;
160
+ }
161
+ /**
162
+ * A region whose entities are a hosted store's collection, with the handoff
163
+ * ledger under another top-level key of the SAME document — so the store's
164
+ * persistence (`persistStore`) makes both durable as one unit.
165
+ *
166
+ * The definition must accept the ledger key (an object of `outgoing` and
167
+ * `handled` records; {@link parseHandoffLedger} validates one) and should
168
+ * hide it from players (`visibility: { [ledger]: 'nobody' }`).
169
+ */
170
+ export declare function storeRegion<S extends object, E>(host: {
171
+ getState(): S;
172
+ setState(next: S): void;
173
+ }, options: {
174
+ readonly region: string;
175
+ readonly collection: string;
176
+ readonly ledger: string;
177
+ }): Pick<RegionHandoffsOptions<E>, 'load' | 'commit'>;
178
+ /** Validate a handoff ledger read from a store document; `parseEntity` validates the frozen entities. */
179
+ export declare function parseHandoffLedger<E>(value: unknown, parseEntity: (value: unknown) => E): HandoffLedger<E>;