@net-mesh/browser 0.42.0 → 0.44.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 (45) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +12 -0
  3. package/dist/index.bundle.js +2 -2
  4. package/dist/leader/session.d.ts +4 -2
  5. package/dist/leader/session.js +30 -6
  6. package/dist/lobby.d.ts +29 -2
  7. package/dist/lobby.js +80 -10
  8. package/dist/net_leaf.d.ts +19 -21
  9. package/dist/net_leaf.js +42 -44
  10. package/dist/net_leaf_bg.wasm +0 -0
  11. package/dist/net_leaf_bg.wasm.d.ts +10 -10
  12. package/dist/netcode/assignment.d.ts +169 -0
  13. package/dist/netcode/assignment.js +321 -0
  14. package/dist/netcode/client.d.ts +125 -3
  15. package/dist/netcode/client.js +291 -27
  16. package/dist/netcode/codec.d.ts +180 -0
  17. package/dist/netcode/codec.js +547 -0
  18. package/dist/netcode/host.d.ts +58 -1
  19. package/dist/netcode/host.js +254 -9
  20. package/dist/netcode/index.d.ts +14 -4
  21. package/dist/netcode/index.js +8 -3
  22. package/dist/netcode/interpolate.d.ts +24 -1
  23. package/dist/netcode/interpolate.js +52 -1
  24. package/dist/netcode/paths.d.ts +115 -0
  25. package/dist/netcode/paths.js +261 -0
  26. package/dist/netcode/shards.d.ts +138 -0
  27. package/dist/netcode/shards.js +270 -0
  28. package/dist/netcode/wire.d.ts +37 -3
  29. package/dist/netcode/wire.js +28 -1
  30. package/dist/node.d.ts +20 -0
  31. package/dist/node.js +76 -1
  32. package/dist/store/host.js +10 -4
  33. package/dist/store/join.js +38 -2
  34. package/dist/store/owner.d.ts +11 -0
  35. package/dist/store/owner.js +27 -7
  36. package/dist/store/types.d.ts +6 -0
  37. package/dist/stream.d.ts +29 -1
  38. package/dist/stream.js +66 -4
  39. package/dist/udp-probe.d.ts +21 -3
  40. package/dist/udp-probe.js +78 -7
  41. package/dist/world/link.d.ts +9 -0
  42. package/dist/world/link.js +4 -3
  43. package/dist/world/regions.d.ts +9 -2
  44. package/dist/world/regions.js +34 -4
  45. package/package.json +3 -2
@@ -0,0 +1,261 @@
1
+ /**
2
+ * NPCs driven by paths in the store (sharded-match plan, slice N2): an NPC
3
+ * on a known route is published once as a path record, instead of in every
4
+ * snapshot, and every client evaluates the path itself.
5
+ *
6
+ * Two sources, then, for one NPC: snapshots (live, lossy, every tick while
7
+ * live) and the store's path record (reliable, written on a switch). They
8
+ * can arrive in either order, so each carries a **mode version** `mv` that
9
+ * the owner bumps on every switch between `'path'` and `'live'`:
10
+ *
11
+ * - the client draws from **whichever source shows the highest `mv`**;
12
+ * - if that says `'path'` and the record has not arrived yet, the last live
13
+ * motion is carried forward for at most `carryMs` (500), then held;
14
+ * - a switch of source blends over `smoothingMs` (100), so it never jumps;
15
+ * - an NPC whose highest `mv` says `'live'` but that no snapshot shows any
16
+ * more (dead, or out of interest) is held for `carryMs`, then no longer
17
+ * drawn — which is how a retired path record (`'live'`, no points)
18
+ * clears it.
19
+ *
20
+ * ```ts
21
+ * const modes = npcModes<Npc>();
22
+ * renderLoop(() => draw(modes.resolve(match.view(), lobbyReplica.getState().paths, id => match.hostNow(ownerOf(id)))));
23
+ * ```
24
+ *
25
+ * An NPC whose position is secret (fog) never uses a path: a record goes to
26
+ * every reader of the store. The shard host recipe refuses to put such an
27
+ * NPC on a path.
28
+ */
29
+ /** Waypoints one record may carry. */
30
+ export const MAX_PATH_POINTS = 64;
31
+ /** Where a path puts its NPC at owner host time `t`: interpolated, held before the first point and after the last. */
32
+ export function pathAt(points, t) {
33
+ if (points.length === 0)
34
+ return null;
35
+ if (t <= points[0].t)
36
+ return { x: points[0].x, z: points[0].z };
37
+ const last = points.at(-1);
38
+ if (t >= last.t)
39
+ return { x: last.x, z: last.z };
40
+ let i = 1;
41
+ while (points[i].t < t)
42
+ i += 1;
43
+ const a = points[i - 1];
44
+ const b = points[i];
45
+ const alpha = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
46
+ return { x: a.x + (b.x - a.x) * alpha, z: a.z + (b.z - a.z) * alpha };
47
+ }
48
+ /** What is wrong with a path record; empty when it is valid. */
49
+ export function pathProblems(path) {
50
+ const problems = [];
51
+ if (!Number.isSafeInteger(path.mv) || path.mv < 0)
52
+ problems.push('mv is not a non-negative integer');
53
+ if (path.mode !== 'path' && path.mode !== 'live')
54
+ problems.push('mode is neither path nor live');
55
+ if (path.key !== null && typeof path.key !== 'string')
56
+ problems.push('key is neither a string nor null');
57
+ if (!Array.isArray(path.points) || path.points.length > MAX_PATH_POINTS)
58
+ problems.push(`more than ${MAX_PATH_POINTS} points`);
59
+ else {
60
+ if (path.mode === 'path' && path.points.length === 0)
61
+ problems.push('a path with no points');
62
+ for (let i = 0; i < path.points.length; i += 1) {
63
+ const p = path.points[i];
64
+ if (![p.t, p.x, p.z].every(Number.isFinite))
65
+ problems.push(`point ${i} is not finite`);
66
+ if (i > 0 && p.t < path.points[i - 1].t)
67
+ problems.push(`point ${i} goes back in time`);
68
+ }
69
+ }
70
+ return problems;
71
+ }
72
+ /**
73
+ * The lobby store's `setPath` action (D8): record `path` for `npc`. Refused
74
+ * (the action rejects, the state is unchanged) unless the caller hosts the
75
+ * shard that owns `npc` in the installed assignment, the record is valid,
76
+ * and its `mv` is above the one held.
77
+ */
78
+ export function pathAction(options) {
79
+ const field = options.field ?? 'paths';
80
+ return (input, context) => {
81
+ const state = context.getState();
82
+ if (typeof input?.npc !== 'string')
83
+ throw new Error('npc is not a string');
84
+ const assignment = options.assignment(state);
85
+ // An array check, not just `includes`: on a string, `includes` would be
86
+ // a substring match.
87
+ const owner = Object.values(assignment?.shards ?? {}).find(shard => Array.isArray(shard.npcs) && shard.npcs.includes(input.npc));
88
+ if (owner === undefined || owner.host !== context.peer)
89
+ throw new Error(`only the host that owns ${input.npc} writes its path`);
90
+ const problems = pathProblems(input.path);
91
+ if (problems.length > 0)
92
+ throw new Error(`invalid path: ${problems.join('; ')}`);
93
+ const records = (state[field] ?? {});
94
+ const held = Object.hasOwn(records, input.npc) ? records[input.npc] : undefined;
95
+ if (held !== undefined && input.path.mv <= held.mv)
96
+ throw new Error(`mv ${input.path.mv} is not above ${held.mv}`);
97
+ context.setState({ [field]: { ...records, [input.npc]: input.path } });
98
+ return { mv: input.path.mv };
99
+ };
100
+ }
101
+ /** Resolve NPCs between their snapshot entries and their path records. */
102
+ export function npcModes(options = {}) {
103
+ const now = options.now ?? (() => globalThis.performance?.now() ?? Date.now());
104
+ const smoothing = Math.max(0, options.smoothingMs ?? 100);
105
+ const carryMs = Math.max(0, options.carryMs ?? 500);
106
+ const delay = options.interpolationDelayMs ?? 100;
107
+ const tracks = new Map();
108
+ let carried = 0;
109
+ let switches = 0;
110
+ const place = (base, at, mv, mode) => ({ ...base, x: at.x, z: at.z, mv, mode });
111
+ return {
112
+ resolve(view, records, ownerNow) {
113
+ const out = { ...view };
114
+ const at = now();
115
+ const ids = new Set();
116
+ for (const [id, entity] of Object.entries(view))
117
+ if (entity.mv !== undefined)
118
+ ids.add(id);
119
+ for (const id of Object.keys(records ?? {}))
120
+ ids.add(id);
121
+ for (const id of ids) {
122
+ let track = tracks.get(id);
123
+ if (track === undefined) {
124
+ track = {
125
+ live: null,
126
+ previousLive: null,
127
+ drawn: null,
128
+ source: null,
129
+ blend: null,
130
+ carriedSince: null,
131
+ seen: null,
132
+ last: null,
133
+ missingSince: null,
134
+ seenAt: at,
135
+ };
136
+ tracks.set(id, track);
137
+ }
138
+ track.seenAt = at;
139
+ const entry = Object.hasOwn(view, id) ? view[id] : undefined;
140
+ const record = records !== null && records !== undefined && Object.hasOwn(records, id) ? records[id] : undefined;
141
+ if (entry !== undefined)
142
+ track.last = entry;
143
+ if (entry !== undefined && entry.mode !== 'path' && (track.live === null || track.live.state !== entry)) {
144
+ track.previousLive = track.live;
145
+ track.live = { state: entry, at };
146
+ }
147
+ if (entry?.mv !== undefined && (track.seen === null || entry.mv >= track.seen.mv)) {
148
+ track.seen = { mv: entry.mv, mode: entry.mode ?? 'live' };
149
+ }
150
+ const entryMv = track.seen?.mv ?? -1;
151
+ const recordMv = record?.mv ?? -1;
152
+ // The highest mode version decides — whichever source carried it.
153
+ const mode = entryMv >= recordMv ? track.seen?.mode ?? 'live' : record.mode;
154
+ const mv = Math.max(entryMv, recordMv);
155
+ let target = null;
156
+ let source = null;
157
+ if (mode !== 'live')
158
+ track.missingSince = null;
159
+ if (mode === 'live') {
160
+ if (entry !== undefined && entry.mode !== 'path') {
161
+ track.missingSince = null;
162
+ target = entry;
163
+ }
164
+ else {
165
+ // Live, but no snapshot shows it: held for carryMs (its entry
166
+ // may be a tick behind a switch back to live), then gone.
167
+ track.missingSince ??= at;
168
+ if (at - track.missingSince > carryMs || track.drawn === null) {
169
+ delete out[id];
170
+ track.drawn = null;
171
+ track.source = null;
172
+ track.blend = null;
173
+ continue;
174
+ }
175
+ target = track.drawn;
176
+ }
177
+ source = 'live';
178
+ }
179
+ else if (record !== undefined && record.mode === 'path' && record.mv >= mv) {
180
+ const clock = ownerNow(id);
181
+ // The freshest state there is: in the announce window the entry
182
+ // itself, after it the last one any snapshot showed.
183
+ const base = entry ?? track.last ?? track.live?.state ?? track.drawn;
184
+ if (clock === null) {
185
+ // The record is here; only the owner's clock is not yet. Hold
186
+ // what was drawn: nothing is late, so this is not a carry.
187
+ const held = track.drawn ?? base;
188
+ if (held === null)
189
+ delete out[id];
190
+ else
191
+ out[id] = held;
192
+ continue;
193
+ }
194
+ const point = pathAt(record.points, clock - delay);
195
+ if (point !== null && base !== null) {
196
+ target = place(base, point, mv, 'path');
197
+ source = 'path';
198
+ }
199
+ }
200
+ else if (entry !== undefined && entry.mode === 'path' && entryMv >= mv) {
201
+ // The record is late, but the owner is still announcing the
202
+ // switch in its snapshots: their position is the path's.
203
+ target = entry;
204
+ source = 'path';
205
+ }
206
+ if (target === null) {
207
+ // Path due, record not here: carry the last live motion forward
208
+ // for at most carryMs, then hold.
209
+ const live = track.live;
210
+ if (live === null) {
211
+ if (track.drawn !== null)
212
+ out[id] = track.drawn;
213
+ else
214
+ delete out[id];
215
+ continue;
216
+ }
217
+ if (track.source !== 'carry') {
218
+ track.carriedSince = at;
219
+ carried += 1;
220
+ }
221
+ const ahead = Math.min(at - live.at, carryMs);
222
+ const before = track.previousLive;
223
+ let x = live.state.x;
224
+ let z = live.state.z;
225
+ if (before !== null && live.at > before.at) {
226
+ const dt = live.at - before.at;
227
+ x += ((live.state.x - before.state.x) / dt) * ahead;
228
+ z += ((live.state.z - before.state.z) / dt) * ahead;
229
+ }
230
+ target = place(live.state, { x, z }, mv, 'path');
231
+ source = 'carry';
232
+ }
233
+ if (track.source !== null && source !== track.source && track.drawn !== null) {
234
+ switches += 1;
235
+ track.blend = { from: track.drawn, at };
236
+ }
237
+ track.source = source;
238
+ let drawn = target;
239
+ if (track.blend !== null) {
240
+ const alpha = smoothing === 0 ? 1 : (at - track.blend.at) / smoothing;
241
+ if (alpha >= 1)
242
+ track.blend = null;
243
+ else {
244
+ const from = track.blend.from;
245
+ drawn = { ...drawn, x: from.x + (drawn.x - from.x) * alpha, z: from.z + (drawn.z - from.z) * alpha };
246
+ }
247
+ }
248
+ track.drawn = drawn;
249
+ out[id] = drawn;
250
+ }
251
+ // An NPC neither source shows any more is forgotten, once it has been
252
+ // gone longer than anything here could still carry or blend it.
253
+ for (const [id, track] of tracks) {
254
+ if (!ids.has(id) && at - track.seenAt > carryMs + smoothing)
255
+ tracks.delete(id);
256
+ }
257
+ return out;
258
+ },
259
+ stats: () => ({ carried, switches, tracks: tracks.size }),
260
+ };
261
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `joinShards` — one player's merged view of a match split across several
3
+ * hosts (sharded-match plan, slice S1; topology A: every player joins
4
+ * every host).
5
+ *
6
+ * ```ts
7
+ * const match = joinShards<Ship, Move>({
8
+ * transport: node,
9
+ * label: 'arena.move',
10
+ * assignment, // { epoch, hosts: { [shard]: nodeHex }, owner: { [id]: shard } }
11
+ * local: { id: myId, predict: move },
12
+ * interest: cellsAround(x, z),
13
+ * });
14
+ * renderLoop(() => draw(match.view())); // merged: remote interpolated, own predicted
15
+ * onKey(input => match.input(input)); // to the shard that owns you, only
16
+ * lobby.onAssignment(next => match.setAssignment(next));
17
+ * ```
18
+ *
19
+ * One {@link joinNetcode} per shard host, with `local` only on the shard
20
+ * that owns this player. **An entity is drawn only from the shard that
21
+ * owns it at the installed epoch**, so a host can show nothing it does not
22
+ * own:
23
+ *
24
+ * - an entry from any other shard is dropped (`droppedNotOwner`);
25
+ * - a shard whose newest snapshot is from another epoch is **frozen**: what
26
+ * it sends is not drawn, and its entities stay at their last drawn state,
27
+ * minus any it no longer owns, until it reaches the installed epoch (an
28
+ * older one: `droppedStaleEpoch` counts the entries refused) or
29
+ * {@link ShardedMatch.setAssignment} installs its newer one here
30
+ * (`heldNewerEpoch`). Freezing rather than blanking an older shard means
31
+ * a player that installs an epoch before its hosts do sees no blink.
32
+ *
33
+ * Counts are per snapshot a shard delivered, not per frame drawn.
34
+ *
35
+ * When an entity's owner changes, the old owner's last copy lingers for
36
+ * `lingerMs` or until the new owner first shows it, whichever is first —
37
+ * as `joinWorld` lingers a departed region's entities — so a handover does
38
+ * not blink. A shard that is rejoined (its host changed, or this player's
39
+ * owner moved to or from it) keeps showing what it last drew, minus what it
40
+ * no longer owns, until the new connection delivers its first snapshot: so
41
+ * a planned move does not blank the rest of either shard for a join round
42
+ * trip.
43
+ *
44
+ * What this is not: the host's half (simulate only what you own, refuse
45
+ * input for others, leases) is the shard host recipe's (S2), and where the
46
+ * assignment comes from is the lobby's (S4).
47
+ */
48
+ import { type JoinNetcodeOptions, type LocalEntity, type NetcodeStats } from './client.js';
49
+ import { type NetcodeTransport } from './wire.js';
50
+ /** Who hosts which shard and who owns which entity, at one epoch. */
51
+ export interface ShardAssignment {
52
+ /** Strictly increasing; a lower or equal one is never installed over a higher. */
53
+ readonly epoch: number;
54
+ /** Shard name → its host's node id, 16 hex. */
55
+ readonly hosts: Readonly<Record<string, string>>;
56
+ /** Entity id → the shard that owns it. Omit to resolve with `ownerOf`. */
57
+ readonly owner?: Readonly<Record<string, string>>;
58
+ }
59
+ /** {@link joinShards}'s argument. */
60
+ export interface JoinShardsOptions<E, I> extends Omit<JoinNetcodeOptions<E, I>, 'host' | 'local' | 'epoch' | 'transport' | 'label' | 'interest' | 'sequencer'> {
61
+ readonly transport: NetcodeTransport;
62
+ /** The netcode label every shard host serves. */
63
+ readonly label: string;
64
+ readonly assignment: ShardAssignment;
65
+ /**
66
+ * The owning shard of `id` at `assignment`, for an assignment that does
67
+ * not list every entity (NPCs owned by `hash(id) mod hosts`, D7).
68
+ * Default: `assignment.owner[id]`. An entity with no owner is not drawn.
69
+ */
70
+ ownerOf?(id: string, assignment: ShardAssignment): string | undefined;
71
+ /** This player's own entity, predicted locally; its inputs go to its owner only. Omit to spectate. */
72
+ readonly local?: LocalEntity<E, I>;
73
+ /** Interest keys, sent to every shard. */
74
+ readonly interest?: readonly string[];
75
+ /** How long an entity whose owner changed keeps its old owner's last state, ms. Default 1000. */
76
+ readonly lingerMs?: number;
77
+ /**
78
+ * Whether this player still hears the lobby (`leaseWatch` over the
79
+ * assignment's `renewals`). While `false` the view is frozen where it
80
+ * was, inputs are not sent (`inputsFrozen`), and `stats().frozen` is
81
+ * `true` — the game shows "reconnecting".
82
+ */
83
+ live?(): boolean;
84
+ }
85
+ /** Per-shard state, as {@link ShardedMatch.stats} reports it. */
86
+ export interface ShardStats extends NetcodeStats {
87
+ /** The host serving the shard. */
88
+ readonly host: string;
89
+ /** ms since this shard last delivered a snapshot (since joining, before the first). */
90
+ readonly silentMs: number;
91
+ }
92
+ /** What {@link ShardedMatch.stats} reports. */
93
+ export interface ShardedMatchStats {
94
+ /** The installed epoch. */
95
+ readonly epoch: number;
96
+ readonly shards: Readonly<Record<string, ShardStats>>;
97
+ /** Entries a shard sent for an entity it does not own at the installed epoch. */
98
+ readonly droppedNotOwner: number;
99
+ /** Entries dropped because their shard's snapshot was from an older epoch. */
100
+ readonly droppedStaleEpoch: number;
101
+ /** Snapshots from a newer epoch than installed, whose shard was held at its last state. */
102
+ readonly heldNewerEpoch: number;
103
+ /** Inputs not sent: no shard owns this player at the installed epoch. */
104
+ readonly inputsUnowned: number;
105
+ /** Inputs not sent because the view was frozen (`live` false). */
106
+ readonly inputsFrozen: number;
107
+ /** The view is frozen: this player does not hear the lobby. */
108
+ readonly frozen: boolean;
109
+ }
110
+ /** The running merged view. */
111
+ export interface ShardedMatch<E, I> {
112
+ /** Every shard's own entities, merged; yours predicted. */
113
+ view(): Readonly<Record<string, E>>;
114
+ /** Apply `data` locally and send it to the shard that owns you, stamped with the epoch. */
115
+ input(data: I): void;
116
+ /** That shard's host clock, for hit claims. `null` before its first pong, or for an unknown shard. */
117
+ hostNow(shard: string): number | null;
118
+ /** Receive only these interest keys, from every shard. */
119
+ setInterest(keys: readonly string[]): void;
120
+ /**
121
+ * Install a newer assignment. Returns `false`, and changes nothing, for
122
+ * an epoch not above the installed one. A shard whose host changed is
123
+ * rejoined; so are the old and new owners of this player, which moves
124
+ * the prediction to the new owner.
125
+ */
126
+ setAssignment(next: ShardAssignment): boolean;
127
+ /**
128
+ * Resolves once every shard has delivered a snapshot; rejects with the
129
+ * first shard's join failure. Follows rejoins: a shard rejoined by
130
+ * {@link setAssignment} while this waits is waited on afresh, and the
131
+ * closed connection it replaced neither resolves nor rejects it.
132
+ */
133
+ ready(): Promise<void>;
134
+ stats(): ShardedMatchStats;
135
+ close(): void;
136
+ }
137
+ /** Join every shard host of a match and merge their views. */
138
+ export declare function joinShards<E, I>(options: JoinShardsOptions<E, I>): ShardedMatch<E, I>;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * `joinShards` — one player's merged view of a match split across several
3
+ * hosts (sharded-match plan, slice S1; topology A: every player joins
4
+ * every host).
5
+ *
6
+ * ```ts
7
+ * const match = joinShards<Ship, Move>({
8
+ * transport: node,
9
+ * label: 'arena.move',
10
+ * assignment, // { epoch, hosts: { [shard]: nodeHex }, owner: { [id]: shard } }
11
+ * local: { id: myId, predict: move },
12
+ * interest: cellsAround(x, z),
13
+ * });
14
+ * renderLoop(() => draw(match.view())); // merged: remote interpolated, own predicted
15
+ * onKey(input => match.input(input)); // to the shard that owns you, only
16
+ * lobby.onAssignment(next => match.setAssignment(next));
17
+ * ```
18
+ *
19
+ * One {@link joinNetcode} per shard host, with `local` only on the shard
20
+ * that owns this player. **An entity is drawn only from the shard that
21
+ * owns it at the installed epoch**, so a host can show nothing it does not
22
+ * own:
23
+ *
24
+ * - an entry from any other shard is dropped (`droppedNotOwner`);
25
+ * - a shard whose newest snapshot is from another epoch is **frozen**: what
26
+ * it sends is not drawn, and its entities stay at their last drawn state,
27
+ * minus any it no longer owns, until it reaches the installed epoch (an
28
+ * older one: `droppedStaleEpoch` counts the entries refused) or
29
+ * {@link ShardedMatch.setAssignment} installs its newer one here
30
+ * (`heldNewerEpoch`). Freezing rather than blanking an older shard means
31
+ * a player that installs an epoch before its hosts do sees no blink.
32
+ *
33
+ * Counts are per snapshot a shard delivered, not per frame drawn.
34
+ *
35
+ * When an entity's owner changes, the old owner's last copy lingers for
36
+ * `lingerMs` or until the new owner first shows it, whichever is first —
37
+ * as `joinWorld` lingers a departed region's entities — so a handover does
38
+ * not blink. A shard that is rejoined (its host changed, or this player's
39
+ * owner moved to or from it) keeps showing what it last drew, minus what it
40
+ * no longer owns, until the new connection delivers its first snapshot: so
41
+ * a planned move does not blank the rest of either shard for a join round
42
+ * trip.
43
+ *
44
+ * What this is not: the host's half (simulate only what you own, refuse
45
+ * input for others, leases) is the shard host recipe's (S2), and where the
46
+ * assignment comes from is the lobby's (S4).
47
+ */
48
+ import { createInputSequencer, joinNetcode, } from './client.js';
49
+ import { defaultNow, peerHex } from './wire.js';
50
+ /** Join every shard host of a match and merge their views. */
51
+ export function joinShards(options) {
52
+ const now = options.now ?? defaultNow;
53
+ const lingerMs = Math.max(0, options.lingerMs ?? 1000);
54
+ const ownerOf = options.ownerOf ?? ((id, at) => at.owner?.[id]);
55
+ const self = options.local?.id;
56
+ let installed = checked(options.assignment);
57
+ let interest = options.interest;
58
+ let closed = false;
59
+ const shards = new Map();
60
+ const lingering = new Map();
61
+ let droppedNotOwner = 0;
62
+ let droppedStaleEpoch = 0;
63
+ let heldNewerEpoch = 0;
64
+ let inputsUnowned = 0;
65
+ let inputsFrozen = 0;
66
+ let frozenView = {};
67
+ const live = () => options.live?.() ?? true;
68
+ // One sequence for this player across every shard: a move to another
69
+ // owner continues it, and re-sends what the old owner never acknowledged.
70
+ const sequencer = createInputSequencer();
71
+ // Everything a shard's `joinNetcode` takes as given; the rest is per shard.
72
+ const { assignment: _assignment, ownerOf: _ownerOf, local: _local, interest: _interest, lingerMs: _lingerMs, live: _live, ...netcodeOptions } = options;
73
+ const selfOwner = () => (self === undefined ? undefined : ownerOf(self, installed));
74
+ const join = (shard, host, drawn = {}) => {
75
+ const owns = shard === selfOwner();
76
+ const client = joinNetcode({
77
+ ...netcodeOptions,
78
+ host,
79
+ ...(owns && options.local !== undefined ? { local: options.local } : {}),
80
+ ...(interest === undefined ? {} : { interest }),
81
+ epoch: () => installed.epoch,
82
+ sequencer,
83
+ now,
84
+ });
85
+ return { host, client, owns, drawn, seen: 0, joinedAt: now() };
86
+ };
87
+ for (const [shard, host] of Object.entries(installed.hosts))
88
+ shards.set(shard, join(shard, host));
89
+ return {
90
+ view() {
91
+ if (!live())
92
+ return frozenView;
93
+ const out = {};
94
+ const at = now();
95
+ for (const [shard, entry] of shards) {
96
+ const stats = entry.client.stats();
97
+ const fresh = stats.snapshots !== entry.seen;
98
+ entry.seen = stats.snapshots;
99
+ const epoch = stats.epoch;
100
+ if (stats.snapshots === 0) {
101
+ // Rejoined, and nothing from the new connection yet: what the old
102
+ // one last drew holds, minus what this shard no longer owns —
103
+ // the same rule as a frozen shard, so a rejoin does not blink.
104
+ const held = {};
105
+ for (const [id, state] of Object.entries(entry.drawn)) {
106
+ if (ownerOf(id, installed) === shard)
107
+ held[id] = state;
108
+ }
109
+ entry.drawn = held;
110
+ Object.assign(out, held);
111
+ continue;
112
+ }
113
+ if (epoch !== null && epoch !== installed.epoch) {
114
+ // Not at our epoch: nothing it sends now is drawn. It is frozen
115
+ // where it was last drawn, minus what it no longer owns — so a
116
+ // player that installs an epoch before its hosts do does not see
117
+ // the world blink, and a non-owner is still never drawn.
118
+ if (fresh) {
119
+ if (epoch > installed.epoch)
120
+ heldNewerEpoch += 1;
121
+ else
122
+ droppedStaleEpoch += Object.keys(entry.client.view()).length;
123
+ }
124
+ const frozen = {};
125
+ for (const [id, state] of Object.entries(entry.drawn)) {
126
+ if (ownerOf(id, installed) === shard)
127
+ frozen[id] = state;
128
+ }
129
+ entry.drawn = frozen;
130
+ Object.assign(out, frozen);
131
+ continue;
132
+ }
133
+ const entities = entry.client.view();
134
+ const drawn = {};
135
+ for (const id of Object.keys(entities)) {
136
+ if (ownerOf(id, installed) !== shard) {
137
+ if (fresh)
138
+ droppedNotOwner += 1;
139
+ continue;
140
+ }
141
+ drawn[id] = entities[id];
142
+ lingering.delete(id);
143
+ }
144
+ entry.drawn = drawn;
145
+ Object.assign(out, drawn);
146
+ }
147
+ for (const [id, { state, until }] of lingering) {
148
+ if (until <= at)
149
+ lingering.delete(id);
150
+ else if (!(id in out))
151
+ out[id] = state;
152
+ }
153
+ frozenView = out;
154
+ return out;
155
+ },
156
+ input(data) {
157
+ if (closed)
158
+ return;
159
+ if (!live()) {
160
+ inputsFrozen += 1;
161
+ return;
162
+ }
163
+ const owner = selfOwner();
164
+ const entry = owner === undefined ? undefined : shards.get(owner);
165
+ if (entry === undefined || !entry.owns) {
166
+ inputsUnowned += 1;
167
+ return;
168
+ }
169
+ entry.client.input(data);
170
+ },
171
+ hostNow: shard => shards.get(shard)?.client.hostNow() ?? null,
172
+ setInterest(keys) {
173
+ interest = [...keys];
174
+ for (const entry of shards.values())
175
+ entry.client.setInterest(keys);
176
+ },
177
+ setAssignment(next) {
178
+ if (closed)
179
+ return false;
180
+ const candidate = checked(next);
181
+ if (candidate.epoch <= installed.epoch)
182
+ return false;
183
+ const previous = installed;
184
+ const previousSelfOwner = selfOwner();
185
+ // What was on screen, before ownership moves: a handover lingers.
186
+ const at = now();
187
+ for (const [shard, entry] of shards) {
188
+ for (const [id, state] of Object.entries(entry.drawn)) {
189
+ if (ownerOf(id, candidate) !== shard && ownerOf(id, previous) === shard) {
190
+ lingering.set(id, { state, until: at + lingerMs });
191
+ }
192
+ }
193
+ }
194
+ installed = candidate;
195
+ const nextSelfOwner = selfOwner();
196
+ // What a rejoined shard goes on showing until its first snapshot.
197
+ const carried = new Map();
198
+ for (const [shard, entry] of shards) {
199
+ const host = candidate.hosts[shard];
200
+ const ownershipMoved = previousSelfOwner !== nextSelfOwner && (shard === previousSelfOwner || shard === nextSelfOwner);
201
+ if (host === entry.host && !ownershipMoved)
202
+ continue;
203
+ entry.client.close();
204
+ shards.delete(shard);
205
+ if (host !== undefined)
206
+ carried.set(shard, entry.drawn);
207
+ }
208
+ for (const [shard, host] of Object.entries(candidate.hosts)) {
209
+ if (!shards.has(shard))
210
+ shards.set(shard, join(shard, host, carried.get(shard)));
211
+ }
212
+ return true;
213
+ },
214
+ ready() {
215
+ // Waits on the live connections: one replaced while this waits is
216
+ // dropped from the wait (its close rejects its own `ready()`), and the
217
+ // set is checked again once the rest settle.
218
+ const current = (entry) => [...shards.values()].includes(entry);
219
+ const wait = () => {
220
+ const entries = [...shards.values()];
221
+ return Promise.all(entries.map(entry => entry.client.ready().catch((error) => {
222
+ if (closed || current(entry))
223
+ throw error;
224
+ }))).then(() => (closed || entries.every(current) ? undefined : wait()));
225
+ };
226
+ return wait();
227
+ },
228
+ stats() {
229
+ const at = now();
230
+ const perShard = {};
231
+ for (const [shard, entry] of shards) {
232
+ const stats = entry.client.stats();
233
+ perShard[shard] = { ...stats, host: entry.host, silentMs: at - (stats.lastSnapshotAt ?? entry.joinedAt) };
234
+ }
235
+ return {
236
+ epoch: installed.epoch,
237
+ shards: perShard,
238
+ droppedNotOwner,
239
+ droppedStaleEpoch,
240
+ heldNewerEpoch,
241
+ inputsUnowned,
242
+ inputsFrozen,
243
+ frozen: !live(),
244
+ };
245
+ },
246
+ close() {
247
+ if (closed)
248
+ return;
249
+ closed = true;
250
+ for (const entry of shards.values())
251
+ entry.client.close();
252
+ shards.clear();
253
+ lingering.clear();
254
+ },
255
+ };
256
+ }
257
+ /** An assignment with its host ids normalised, or a `TypeError`. */
258
+ function checked(assignment) {
259
+ if (!Number.isSafeInteger(assignment.epoch) || assignment.epoch < 0) {
260
+ throw new TypeError(`assignment epoch must be a non-negative integer, got ${String(assignment.epoch)}`);
261
+ }
262
+ const hosts = {};
263
+ for (const [shard, host] of Object.entries(assignment.hosts)) {
264
+ const hex = peerHex(host);
265
+ if (hex === null)
266
+ throw new TypeError(`shard ${shard}: host is not a node id: ${JSON.stringify(host)}`);
267
+ hosts[shard] = hex;
268
+ }
269
+ return { ...assignment, hosts };
270
+ }