@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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch semantics, frozen (brief §1.13).
|
|
3
|
+
*
|
|
4
|
+
* A delta is a list of `{"o":"r","p":[…],"val":…}` (replace) and
|
|
5
|
+
* `{"o":"x","p":[…]}` (remove) operations over **own property-name
|
|
6
|
+
* segments**. There is no JSON Pointer escaping, no arbitrary
|
|
7
|
+
* operation, and nothing executable: the op set is two entries because
|
|
8
|
+
* a patch language is an interpreter, and an interpreter on untrusted
|
|
9
|
+
* input is a much larger promise than this protocol needs to make.
|
|
10
|
+
*
|
|
11
|
+
* Three properties carry the weight, and each is a witness rather than
|
|
12
|
+
* a comment:
|
|
13
|
+
*
|
|
14
|
+
* - **Atomicity.** The whole patch is validated, applied to a draft,
|
|
15
|
+
* and the *final* state validated by the definition's `state()`
|
|
16
|
+
* before anything is committed. A half-applied patch is never
|
|
17
|
+
* published, and a refused patch leaves the previous revision
|
|
18
|
+
* byte-for-byte intact — the caller then resynchronizes (§1.8),
|
|
19
|
+
* because a replica whose view disagrees with the owner's must not
|
|
20
|
+
* guess.
|
|
21
|
+
* - **Prototype safety.** `__proto__`, `constructor` and `prototype`
|
|
22
|
+
* are refused as segments, traversal is own-property only, and every
|
|
23
|
+
* node the patch builds has a **null prototype**. Refusing the names
|
|
24
|
+
* alone would not be enough — a draft built from `{}` inherits a
|
|
25
|
+
* `constructor` that `in` would find — so the two rules are both
|
|
26
|
+
* present and both witnessed.
|
|
27
|
+
* - **Identity.** The committed state goes through
|
|
28
|
+
* {@link reconcile}, so untouched subtrees keep their references and
|
|
29
|
+
* a Three.js loop can diff by reference. An equivalent patch is a
|
|
30
|
+
* no-op, not a whole-world change.
|
|
31
|
+
*/
|
|
32
|
+
import type { StoreErrorCode } from './errors.js';
|
|
33
|
+
import type { Parse } from './types.js';
|
|
34
|
+
import { type WireOp } from './wire.js';
|
|
35
|
+
/** Segments that would reach the prototype chain. */
|
|
36
|
+
export declare const FORBIDDEN_SEGMENTS: readonly string[];
|
|
37
|
+
/** Why a patch was refused. Stable and countable. */
|
|
38
|
+
export type PatchRefusal = 'ops-too-many' | 'path-too-deep' | 'segment-too-long' | 'segment-forbidden' | 'root-remove' | 'missing-parent' | 'parent-not-object' | 'missing-key' | 'invalid-state';
|
|
39
|
+
export interface PatchRejected {
|
|
40
|
+
readonly ok: false;
|
|
41
|
+
readonly code: StoreErrorCode;
|
|
42
|
+
readonly reason: PatchRefusal;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the replica should resynchronize. True for the divergence
|
|
45
|
+
* refusals (`missing-parent`, `missing-key`, `parent-not-object`,
|
|
46
|
+
* `invalid-state`): the views disagree and only the owner can settle
|
|
47
|
+
* it. False for the bound refusals, which are the sender's fault.
|
|
48
|
+
*/
|
|
49
|
+
readonly resync: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface PatchApplied<S> {
|
|
52
|
+
readonly ok: true;
|
|
53
|
+
readonly next: S;
|
|
54
|
+
/**
|
|
55
|
+
* False when the patch moved nothing — `next === current`. An
|
|
56
|
+
* unchanged update must not create a revision or a notification
|
|
57
|
+
* (design §4), and that decision is made here, once.
|
|
58
|
+
*/
|
|
59
|
+
readonly changed: boolean;
|
|
60
|
+
}
|
|
61
|
+
export type PatchOutcome<S> = PatchApplied<S> | PatchRejected;
|
|
62
|
+
/**
|
|
63
|
+
* Apply a delta's operations atomically.
|
|
64
|
+
*
|
|
65
|
+
* `current` is never mutated, whatever the outcome: on refusal the
|
|
66
|
+
* caller still holds exactly the state it had, which is what makes
|
|
67
|
+
* "a failed patch leaves the previous revision intact" a property of
|
|
68
|
+
* this function rather than a rule its callers have to remember.
|
|
69
|
+
*/
|
|
70
|
+
export declare function applyPatch<S extends object>(current: S, ops: readonly WireOp[], validate: Parse<S>): PatchOutcome<S>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch semantics, frozen (brief §1.13).
|
|
3
|
+
*
|
|
4
|
+
* A delta is a list of `{"o":"r","p":[…],"val":…}` (replace) and
|
|
5
|
+
* `{"o":"x","p":[…]}` (remove) operations over **own property-name
|
|
6
|
+
* segments**. There is no JSON Pointer escaping, no arbitrary
|
|
7
|
+
* operation, and nothing executable: the op set is two entries because
|
|
8
|
+
* a patch language is an interpreter, and an interpreter on untrusted
|
|
9
|
+
* input is a much larger promise than this protocol needs to make.
|
|
10
|
+
*
|
|
11
|
+
* Three properties carry the weight, and each is a witness rather than
|
|
12
|
+
* a comment:
|
|
13
|
+
*
|
|
14
|
+
* - **Atomicity.** The whole patch is validated, applied to a draft,
|
|
15
|
+
* and the *final* state validated by the definition's `state()`
|
|
16
|
+
* before anything is committed. A half-applied patch is never
|
|
17
|
+
* published, and a refused patch leaves the previous revision
|
|
18
|
+
* byte-for-byte intact — the caller then resynchronizes (§1.8),
|
|
19
|
+
* because a replica whose view disagrees with the owner's must not
|
|
20
|
+
* guess.
|
|
21
|
+
* - **Prototype safety.** `__proto__`, `constructor` and `prototype`
|
|
22
|
+
* are refused as segments, traversal is own-property only, and every
|
|
23
|
+
* node the patch builds has a **null prototype**. Refusing the names
|
|
24
|
+
* alone would not be enough — a draft built from `{}` inherits a
|
|
25
|
+
* `constructor` that `in` would find — so the two rules are both
|
|
26
|
+
* present and both witnessed.
|
|
27
|
+
* - **Identity.** The committed state goes through
|
|
28
|
+
* {@link reconcile}, so untouched subtrees keep their references and
|
|
29
|
+
* a Three.js loop can diff by reference. An equivalent patch is a
|
|
30
|
+
* no-op, not a whole-world change.
|
|
31
|
+
*/
|
|
32
|
+
import { reconcile } from './state.js';
|
|
33
|
+
import { MAX_PATCH_OPS, MAX_PATH_SEGMENTS, MAX_PATH_SEGMENT_BYTES, utf8Length } from './wire.js';
|
|
34
|
+
/** Segments that would reach the prototype chain. */
|
|
35
|
+
export const FORBIDDEN_SEGMENTS = ['__proto__', 'constructor', 'prototype'];
|
|
36
|
+
const FORBIDDEN = new Set(FORBIDDEN_SEGMENTS);
|
|
37
|
+
function reject(reason, resync, code = 'invalid-data') {
|
|
38
|
+
return { ok: false, code, reason, resync };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Apply a delta's operations atomically.
|
|
42
|
+
*
|
|
43
|
+
* `current` is never mutated, whatever the outcome: on refusal the
|
|
44
|
+
* caller still holds exactly the state it had, which is what makes
|
|
45
|
+
* "a failed patch leaves the previous revision intact" a property of
|
|
46
|
+
* this function rather than a rule its callers have to remember.
|
|
47
|
+
*/
|
|
48
|
+
export function applyPatch(current, ops, validate) {
|
|
49
|
+
// Bounds first, and over the whole list: nothing is applied until
|
|
50
|
+
// every operation is admissible, so a patch cannot be half-refused.
|
|
51
|
+
if (ops.length > MAX_PATCH_OPS)
|
|
52
|
+
return reject('ops-too-many', false, 'capacity');
|
|
53
|
+
for (const op of ops) {
|
|
54
|
+
if (op.p.length > MAX_PATH_SEGMENTS)
|
|
55
|
+
return reject('path-too-deep', false, 'capacity');
|
|
56
|
+
for (const segment of op.p) {
|
|
57
|
+
if (utf8Length(segment) > MAX_PATH_SEGMENT_BYTES) {
|
|
58
|
+
return reject('segment-too-long', false, 'capacity');
|
|
59
|
+
}
|
|
60
|
+
// Prototype-named segments are refused outright. Traversal below
|
|
61
|
+
// is own-property only as well; either alone would be a hole.
|
|
62
|
+
if (FORBIDDEN.has(segment))
|
|
63
|
+
return reject('segment-forbidden', false);
|
|
64
|
+
}
|
|
65
|
+
// A state must exist: replacing the root is permitted, removing it
|
|
66
|
+
// is not.
|
|
67
|
+
if (op.o === 'x' && op.p.length === 0)
|
|
68
|
+
return reject('root-remove', false);
|
|
69
|
+
}
|
|
70
|
+
// Apply to a draft. Nodes are copied along the touched path only, so
|
|
71
|
+
// an untouched subtree is the *same object* in the draft and is then
|
|
72
|
+
// recognised by `reconcile` as unchanged.
|
|
73
|
+
let draft = current;
|
|
74
|
+
const copied = new WeakSet();
|
|
75
|
+
for (const op of ops) {
|
|
76
|
+
if (op.p.length === 0) {
|
|
77
|
+
// Root replace; root remove was refused above.
|
|
78
|
+
if (op.o === 'r')
|
|
79
|
+
draft = op.val;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!isTraversable(draft))
|
|
83
|
+
return reject('parent-not-object', true);
|
|
84
|
+
const root = copyOnce(draft, copied);
|
|
85
|
+
draft = root;
|
|
86
|
+
const parent = descend(root, op.p.slice(0, -1), copied);
|
|
87
|
+
if (parent === MISSING)
|
|
88
|
+
return reject('missing-parent', true);
|
|
89
|
+
if (parent === NOT_OBJECT)
|
|
90
|
+
return reject('parent-not-object', true);
|
|
91
|
+
const container = parent;
|
|
92
|
+
const leaf = op.p[op.p.length - 1];
|
|
93
|
+
if (op.o === 'x') {
|
|
94
|
+
// A remove whose target is absent means the views have already
|
|
95
|
+
// diverged: refuse the whole patch and resynchronize.
|
|
96
|
+
if (!Object.prototype.hasOwnProperty.call(container, leaf)) {
|
|
97
|
+
return reject('missing-key', true);
|
|
98
|
+
}
|
|
99
|
+
delete container[leaf];
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
container[leaf] = op.val;
|
|
103
|
+
}
|
|
104
|
+
// The definition validates the **final** state, not each step: an
|
|
105
|
+
// intermediate shape may legitimately be invalid, and a patch is one
|
|
106
|
+
// transition.
|
|
107
|
+
let validated;
|
|
108
|
+
try {
|
|
109
|
+
validated = validate(draft);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return reject('invalid-state', true);
|
|
113
|
+
}
|
|
114
|
+
const next = reconcile(current, validated);
|
|
115
|
+
return { ok: true, next, changed: !Object.is(next, current) };
|
|
116
|
+
}
|
|
117
|
+
const MISSING = Symbol('missing-parent');
|
|
118
|
+
const NOT_OBJECT = Symbol('parent-not-object');
|
|
119
|
+
/**
|
|
120
|
+
* Walk to the parent of the target, copying each node on the way.
|
|
121
|
+
*
|
|
122
|
+
* `root` is already a draft copy. Each child is copied once and written
|
|
123
|
+
* back into its (copied) holder, so a second operation through the same
|
|
124
|
+
* parent mutates the draft's node instead of copying it again — which
|
|
125
|
+
* is what makes overlapping operations apply in array order with the
|
|
126
|
+
* later one winning.
|
|
127
|
+
*/
|
|
128
|
+
function descend(root, path, copied) {
|
|
129
|
+
let node = root;
|
|
130
|
+
for (const segment of path) {
|
|
131
|
+
const holder = node;
|
|
132
|
+
// Own-property only: the prototype chain is never followed, so a
|
|
133
|
+
// path segment can never resolve to an inherited member.
|
|
134
|
+
if (!Object.prototype.hasOwnProperty.call(holder, segment))
|
|
135
|
+
return MISSING;
|
|
136
|
+
const child = holder[segment];
|
|
137
|
+
if (!isTraversable(child))
|
|
138
|
+
return NOT_OBJECT;
|
|
139
|
+
const fresh = copyOnce(child, copied);
|
|
140
|
+
holder[segment] = fresh;
|
|
141
|
+
node = fresh;
|
|
142
|
+
}
|
|
143
|
+
return node;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Arrays are replaced whole in v1, so they are not traversable: a path
|
|
147
|
+
* that descends into one is a refusal, not an index.
|
|
148
|
+
*/
|
|
149
|
+
function isTraversable(value) {
|
|
150
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Copy a node into the draft once.
|
|
154
|
+
*
|
|
155
|
+
* The clone has a **null prototype**: refusing `__proto__`,
|
|
156
|
+
* `constructor` and `prototype` as segments stops the names, and this
|
|
157
|
+
* stops the inheritance, so no patched node can reach `Object.prototype`
|
|
158
|
+
* at all.
|
|
159
|
+
*/
|
|
160
|
+
function copyOnce(node, copied) {
|
|
161
|
+
if (copied.has(node))
|
|
162
|
+
return node;
|
|
163
|
+
const clone = Object.assign(Object.create(null), node);
|
|
164
|
+
copied.add(clone);
|
|
165
|
+
return clone;
|
|
166
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hostPlayer` — the hosting node's own player, as a replica-shaped
|
|
3
|
+
* handle.
|
|
4
|
+
*
|
|
5
|
+
* A node cannot join its own store: `openStream({peer})` needs a
|
|
6
|
+
* session with that peer and a node has none with itself, so
|
|
7
|
+
* `joinStore({ host: self })` is refused `invalid-data`. Every game
|
|
8
|
+
* whose host is also a player therefore wrote the same wrapper — the
|
|
9
|
+
* fleet demo's was forty lines — and each one had to remember to hold
|
|
10
|
+
* the host's player to the same `authorize` a replica gets.
|
|
11
|
+
*
|
|
12
|
+
* This is that wrapper, made part of the store, with parity as its
|
|
13
|
+
* contract rather than a habit:
|
|
14
|
+
*
|
|
15
|
+
* - **The same policy.** Reads, actions and inputs go to the host's
|
|
16
|
+
* `authorize` with the host's own node id as `peer`, in the same
|
|
17
|
+
* 16-hex spelling a replica's frames are handed.
|
|
18
|
+
* - **The same transaction.** Input parse, handler, output validation
|
|
19
|
+
* and the result's message budget run in one transaction on the
|
|
20
|
+
* owner, exactly as for a replica's `act` after the wire
|
|
21
|
+
* (`StoreOwner.localAct`), and the change reaches every replica.
|
|
22
|
+
* - **The same values.** Inputs are refused and normalized by the
|
|
23
|
+
* wire's own rules (`encodeMessage`, then `parseStoreJson`), so a
|
|
24
|
+
* value that works for the host works for its players.
|
|
25
|
+
* - **The same view.** `getState()` is the projection for this
|
|
26
|
+
* handle's audience, not the authoritative document: game code that
|
|
27
|
+
* renders from a replica renders the same thing on the host.
|
|
28
|
+
*
|
|
29
|
+
* What is NOT the same, deliberately: there is no wire, so no handle,
|
|
30
|
+
* lease, sequence or replay ledger, and no `reconnect` to perform.
|
|
31
|
+
* Calls still settle asynchronously, as a replica's do, so a handler
|
|
32
|
+
* never runs nested inside the caller's own code.
|
|
33
|
+
*
|
|
34
|
+
* **The trust boundary is unchanged.** The hosting player's page holds
|
|
35
|
+
* the authoritative document; the projection here keeps the host's
|
|
36
|
+
* own UI honest, it does not hide anything from a player who opens the
|
|
37
|
+
* developer tools. Games with real stakes need a dedicated host.
|
|
38
|
+
*/
|
|
39
|
+
import { type HostedStoreHandle } from './host.js';
|
|
40
|
+
import type { JoinedStoreHandle } from './join.js';
|
|
41
|
+
import type { ActionSpec, InputSpec } from './types.js';
|
|
42
|
+
/** What the host's own player reads as. */
|
|
43
|
+
export interface HostPlayerOptions {
|
|
44
|
+
/**
|
|
45
|
+
* The audience this player reads, as a replica's `joinStore({
|
|
46
|
+
* audience })`. Authorized by the host's policy like any other read.
|
|
47
|
+
*/
|
|
48
|
+
readonly audience: readonly string[];
|
|
49
|
+
/** The interest set, as `joinStore({ interest })`. Omit it to see every entity. */
|
|
50
|
+
readonly interest?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The hosting node's own player on `host`.
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* const host = hostStore({ definition: world, transport: node, … });
|
|
57
|
+
* const me = hostPlayer(host, { audience: ['crew'] });
|
|
58
|
+
* await me.ready();
|
|
59
|
+
* await me.act('enlist', { colour: 2 }); // same authorize, same handler
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* Game code can then treat `me` and a `joinStore` replica the same way.
|
|
63
|
+
* Throws `invalid-data` for a handle `hostStore` did not return.
|
|
64
|
+
*/
|
|
65
|
+
export declare function hostPlayer<S extends object, A extends ActionSpec, I extends InputSpec>(host: HostedStoreHandle<S, A, I>, options: HostPlayerOptions): JoinedStoreHandle<S, A, I>;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hostPlayer` — the hosting node's own player, as a replica-shaped
|
|
3
|
+
* handle.
|
|
4
|
+
*
|
|
5
|
+
* A node cannot join its own store: `openStream({peer})` needs a
|
|
6
|
+
* session with that peer and a node has none with itself, so
|
|
7
|
+
* `joinStore({ host: self })` is refused `invalid-data`. Every game
|
|
8
|
+
* whose host is also a player therefore wrote the same wrapper — the
|
|
9
|
+
* fleet demo's was forty lines — and each one had to remember to hold
|
|
10
|
+
* the host's player to the same `authorize` a replica gets.
|
|
11
|
+
*
|
|
12
|
+
* This is that wrapper, made part of the store, with parity as its
|
|
13
|
+
* contract rather than a habit:
|
|
14
|
+
*
|
|
15
|
+
* - **The same policy.** Reads, actions and inputs go to the host's
|
|
16
|
+
* `authorize` with the host's own node id as `peer`, in the same
|
|
17
|
+
* 16-hex spelling a replica's frames are handed.
|
|
18
|
+
* - **The same transaction.** Input parse, handler, output validation
|
|
19
|
+
* and the result's message budget run in one transaction on the
|
|
20
|
+
* owner, exactly as for a replica's `act` after the wire
|
|
21
|
+
* (`StoreOwner.localAct`), and the change reaches every replica.
|
|
22
|
+
* - **The same values.** Inputs are refused and normalized by the
|
|
23
|
+
* wire's own rules (`encodeMessage`, then `parseStoreJson`), so a
|
|
24
|
+
* value that works for the host works for its players.
|
|
25
|
+
* - **The same view.** `getState()` is the projection for this
|
|
26
|
+
* handle's audience, not the authoritative document: game code that
|
|
27
|
+
* renders from a replica renders the same thing on the host.
|
|
28
|
+
*
|
|
29
|
+
* What is NOT the same, deliberately: there is no wire, so no handle,
|
|
30
|
+
* lease, sequence or replay ledger, and no `reconnect` to perform.
|
|
31
|
+
* Calls still settle asynchronously, as a replica's do, so a handler
|
|
32
|
+
* never runs nested inside the caller's own code.
|
|
33
|
+
*
|
|
34
|
+
* **The trust boundary is unchanged.** The hosting player's page holds
|
|
35
|
+
* the authoritative document; the projection here keeps the host's
|
|
36
|
+
* own UI honest, it does not hide anything from a player who opens the
|
|
37
|
+
* developer tools. Games with real stakes need a dedicated host.
|
|
38
|
+
*/
|
|
39
|
+
import { StoreCore } from './core.js';
|
|
40
|
+
import { StoreError } from './errors.js';
|
|
41
|
+
import { hostInternals } from './host.js';
|
|
42
|
+
import { parseStoreJson } from './json.js';
|
|
43
|
+
import { encodeMessage } from './wire.js';
|
|
44
|
+
const PLACEHOLDER_Q = '0'.repeat(16);
|
|
45
|
+
const PLACEHOLDER_H = '0'.repeat(32);
|
|
46
|
+
/**
|
|
47
|
+
* The wire's admission for one caller value: refused exactly as a
|
|
48
|
+
* replica's `encodeMessage` refuses it, and parsed back exactly as the
|
|
49
|
+
* owner's parser hands it to a handler.
|
|
50
|
+
*/
|
|
51
|
+
function admit(name, value, kind) {
|
|
52
|
+
let frame;
|
|
53
|
+
try {
|
|
54
|
+
frame =
|
|
55
|
+
kind === 'act'
|
|
56
|
+
? encodeMessage({ k: 'act', q: PLACEHOLDER_Q, h: PLACEHOLDER_H, s: '1', name, in: value })
|
|
57
|
+
: encodeMessage({ k: 'in', h: PLACEHOLDER_H, s: '1', name, in: value });
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new StoreError('invalid-data', `the input for '${name}' cannot be encoded`, { cause: error });
|
|
61
|
+
}
|
|
62
|
+
const parsed = parseStoreJson(frame);
|
|
63
|
+
if (!parsed.ok || typeof parsed.value !== 'object' || parsed.value === null || Array.isArray(parsed.value)) {
|
|
64
|
+
throw new StoreError('invalid-data', `the input for '${name}' cannot be encoded`);
|
|
65
|
+
}
|
|
66
|
+
return parsed.value.in;
|
|
67
|
+
}
|
|
68
|
+
/** A value as a replica would receive it: JSON, detached from the host's objects. */
|
|
69
|
+
function detach(value) {
|
|
70
|
+
const parsed = parseStoreJson(JSON.stringify(value));
|
|
71
|
+
return parsed.ok ? parsed.value : value;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The hosting node's own player on `host`.
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* const host = hostStore({ definition: world, transport: node, … });
|
|
78
|
+
* const me = hostPlayer(host, { audience: ['crew'] });
|
|
79
|
+
* await me.ready();
|
|
80
|
+
* await me.act('enlist', { colour: 2 }); // same authorize, same handler
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* Game code can then treat `me` and a `joinStore` replica the same way.
|
|
84
|
+
* Throws `invalid-data` for a handle `hostStore` did not return.
|
|
85
|
+
*/
|
|
86
|
+
export function hostPlayer(host, options) {
|
|
87
|
+
const inner = hostInternals(host);
|
|
88
|
+
if (inner === undefined) {
|
|
89
|
+
throw new StoreError('invalid-data', 'hostPlayer needs a handle returned by hostStore');
|
|
90
|
+
}
|
|
91
|
+
const { owner, peer } = inner;
|
|
92
|
+
const definition = host.definition;
|
|
93
|
+
const view = new StoreCore({ definition, initialState: definition.empty(), phase: 'syncing' });
|
|
94
|
+
let audience = [...options.audience];
|
|
95
|
+
let interest = options.interest === undefined ? null : [...options.interest];
|
|
96
|
+
let closed = false;
|
|
97
|
+
/** Terminal: the read was refused, the projection failed, or the host closed. */
|
|
98
|
+
let ended = false;
|
|
99
|
+
const pendingInputs = new Map();
|
|
100
|
+
function end(phase, error) {
|
|
101
|
+
ended = true;
|
|
102
|
+
if (error?.code === 'forbidden')
|
|
103
|
+
queueMicrotask(() => depart('refused'));
|
|
104
|
+
// As a replica does on a terminal refusal: the view is cleared, so
|
|
105
|
+
// a revoked read stops showing what it no longer may.
|
|
106
|
+
view.applySnapshot(definition.empty());
|
|
107
|
+
view.setStatus({ phase, stale: false, error });
|
|
108
|
+
}
|
|
109
|
+
/** Re-read the view — authorized afresh, as a replica's feed is. */
|
|
110
|
+
function refresh() {
|
|
111
|
+
if (closed || ended)
|
|
112
|
+
return;
|
|
113
|
+
if (inner.isClosed()) {
|
|
114
|
+
end('closed', null);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (!owner.localRead(peer, audience)) {
|
|
118
|
+
end('failed', new StoreError('forbidden', 'the store refused: forbidden'));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const projected = owner.localView(peer, audience, interest);
|
|
122
|
+
if (projected === null) {
|
|
123
|
+
end('failed', new StoreError('capacity', 'the store refused: capacity'));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
view.applySnapshot(projected);
|
|
127
|
+
view.setStatus({ phase: 'ready', stale: false, error: null });
|
|
128
|
+
}
|
|
129
|
+
const stopWatching = owner.subscribe(() => {
|
|
130
|
+
refresh();
|
|
131
|
+
});
|
|
132
|
+
inner.onClose(() => {
|
|
133
|
+
stopWatching();
|
|
134
|
+
if (!closed && !ended)
|
|
135
|
+
end('closed', null);
|
|
136
|
+
});
|
|
137
|
+
refresh();
|
|
138
|
+
// A player, as far as `onEvent` is concerned: `join` now if the read
|
|
139
|
+
// was granted, `leave` when this handle ends.
|
|
140
|
+
let joined = false;
|
|
141
|
+
if (view.getStatus().phase === 'ready') {
|
|
142
|
+
joined = true;
|
|
143
|
+
owner.localPresence(peer, true, audience);
|
|
144
|
+
inner.dispatched({ out: [], refused: null, deferred: null });
|
|
145
|
+
}
|
|
146
|
+
function depart(reason) {
|
|
147
|
+
if (!joined || inner.isClosed())
|
|
148
|
+
return;
|
|
149
|
+
joined = false;
|
|
150
|
+
owner.localPresence(peer, false, audience, reason);
|
|
151
|
+
inner.dispatched({ out: [], refused: null, deferred: null });
|
|
152
|
+
}
|
|
153
|
+
function refusal() {
|
|
154
|
+
if (closed)
|
|
155
|
+
return new StoreError('closed', 'this store handle is closed');
|
|
156
|
+
if (inner.isClosed())
|
|
157
|
+
return new StoreError('owner-lost', 'the store owner closed this handle');
|
|
158
|
+
if (ended)
|
|
159
|
+
return view.getStatus().error ?? new StoreError('owner-lost', 'the store owner closed this handle');
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
function flushInputs() {
|
|
163
|
+
const batch = [...pendingInputs];
|
|
164
|
+
pendingInputs.clear();
|
|
165
|
+
if (refusal() !== null)
|
|
166
|
+
return;
|
|
167
|
+
for (const [name, value] of batch)
|
|
168
|
+
inner.dispatched(owner.localInput(name, value, peer));
|
|
169
|
+
}
|
|
170
|
+
function subscribe(first, second, selectorOptions) {
|
|
171
|
+
return second === undefined
|
|
172
|
+
? view.subscribe(first)
|
|
173
|
+
: view.subscribe(first, second, selectorOptions);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
getState: () => view.getState(),
|
|
177
|
+
subscribe,
|
|
178
|
+
getStatus: () => view.getStatus(),
|
|
179
|
+
subscribeStatus: listener => view.subscribeStatus(listener),
|
|
180
|
+
ready: () => {
|
|
181
|
+
const status = view.getStatus();
|
|
182
|
+
if (status.phase === 'ready')
|
|
183
|
+
return Promise.resolve();
|
|
184
|
+
return Promise.reject(status.error ?? new StoreError(closed ? 'closed' : 'owner-lost', 'this store handle is not ready'));
|
|
185
|
+
},
|
|
186
|
+
act: async (name, input) => {
|
|
187
|
+
const early = refusal();
|
|
188
|
+
if (early !== null)
|
|
189
|
+
throw early;
|
|
190
|
+
const value = admit(name, input, 'act');
|
|
191
|
+
// A turn later, as a replica's answer is: a handler never runs
|
|
192
|
+
// inside the caller's own frame — including inside another
|
|
193
|
+
// handler, where it would nest a transaction.
|
|
194
|
+
await Promise.resolve();
|
|
195
|
+
const late = refusal();
|
|
196
|
+
if (late !== null)
|
|
197
|
+
throw late;
|
|
198
|
+
const { outcome, dispatched } = owner.localAct(name, value, peer);
|
|
199
|
+
inner.dispatched(dispatched);
|
|
200
|
+
if (outcome.kind === 'refusal') {
|
|
201
|
+
throw new StoreError(outcome.code, `the store refused: ${outcome.code}`);
|
|
202
|
+
}
|
|
203
|
+
return detach(outcome.out);
|
|
204
|
+
},
|
|
205
|
+
input: (name, value) => {
|
|
206
|
+
if (refusal() !== null || view.getStatus().phase !== 'ready') {
|
|
207
|
+
return { type: 'dropped', reason: 'not-ready' };
|
|
208
|
+
}
|
|
209
|
+
const admitted = admit(name, value, 'in');
|
|
210
|
+
// Newest wins, as a replica's inputs do: one pending value per
|
|
211
|
+
// name, applied a turn later.
|
|
212
|
+
const idle = pendingInputs.size === 0;
|
|
213
|
+
const replaced = pendingInputs.has(name);
|
|
214
|
+
pendingInputs.set(name, admitted);
|
|
215
|
+
if (idle)
|
|
216
|
+
queueMicrotask(flushInputs);
|
|
217
|
+
return { type: replaced ? 'replaced' : 'queued' };
|
|
218
|
+
},
|
|
219
|
+
setAudience: async (names) => {
|
|
220
|
+
const early = refusal();
|
|
221
|
+
if (early !== null)
|
|
222
|
+
throw early;
|
|
223
|
+
try {
|
|
224
|
+
encodeMessage({ k: 'aud', q: PLACEHOLDER_Q, h: PLACEHOLDER_H, aud: [...names] });
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
throw new StoreError('invalid-data', 'the audience cannot be encoded', { cause: error });
|
|
228
|
+
}
|
|
229
|
+
await Promise.resolve();
|
|
230
|
+
const late = refusal();
|
|
231
|
+
if (late !== null)
|
|
232
|
+
throw late;
|
|
233
|
+
if (!owner.localRead(peer, names)) {
|
|
234
|
+
throw new StoreError('forbidden', 'the store refused: forbidden');
|
|
235
|
+
}
|
|
236
|
+
audience = [...names];
|
|
237
|
+
refresh();
|
|
238
|
+
},
|
|
239
|
+
setInterest: async (keys) => {
|
|
240
|
+
const early = refusal();
|
|
241
|
+
if (early !== null)
|
|
242
|
+
throw early;
|
|
243
|
+
const next = [...keys];
|
|
244
|
+
await Promise.resolve();
|
|
245
|
+
interest = next;
|
|
246
|
+
refresh();
|
|
247
|
+
},
|
|
248
|
+
// Nothing to resume: there is no session under this handle.
|
|
249
|
+
reconnect: () => Promise.resolve(),
|
|
250
|
+
close: () => {
|
|
251
|
+
if (!closed) {
|
|
252
|
+
closed = true;
|
|
253
|
+
depart('left');
|
|
254
|
+
pendingInputs.clear();
|
|
255
|
+
stopWatching();
|
|
256
|
+
view.close();
|
|
257
|
+
}
|
|
258
|
+
return Promise.resolve();
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|