@net-mesh/browser 0.43.0 → 0.44.1
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 +176 -0
- package/dist/index.bundle.js +2 -2
- package/dist/lobby.d.ts +29 -2
- package/dist/lobby.js +72 -6
- package/dist/net_leaf.d.ts +19 -21
- package/dist/net_leaf.js +42 -44
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +10 -10
- package/dist/netcode/assignment.d.ts +169 -0
- package/dist/netcode/assignment.js +321 -0
- package/dist/netcode/client.d.ts +125 -3
- package/dist/netcode/client.js +291 -27
- package/dist/netcode/codec.d.ts +180 -0
- package/dist/netcode/codec.js +547 -0
- package/dist/netcode/host.d.ts +58 -1
- package/dist/netcode/host.js +254 -9
- package/dist/netcode/index.d.ts +14 -4
- package/dist/netcode/index.js +8 -3
- package/dist/netcode/interpolate.d.ts +24 -1
- package/dist/netcode/interpolate.js +52 -1
- package/dist/netcode/paths.d.ts +115 -0
- package/dist/netcode/paths.js +261 -0
- package/dist/netcode/shards.d.ts +138 -0
- package/dist/netcode/shards.js +270 -0
- package/dist/netcode/wire.d.ts +37 -3
- package/dist/netcode/wire.js +28 -1
- package/dist/node.d.ts +5 -0
- package/dist/node.js +1 -0
- package/dist/world/link.d.ts +9 -0
- package/dist/world/link.js +4 -3
- package/dist/world/regions.d.ts +9 -2
- package/dist/world/regions.js +34 -4
- package/package.json +3 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The match assignment: who hosts which shard, which squads each shard
|
|
3
|
+
* owns, and each host's lease (sharded-match plan, slice S4).
|
|
4
|
+
*
|
|
5
|
+
* It lives in the **lobby's store**, so every host and player holds a
|
|
6
|
+
* replica of the one record the lobby node wrote. It is written only by
|
|
7
|
+
* the lobby node: {@link assignAction} is the store action that installs
|
|
8
|
+
* one, and it refuses every other caller.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // The lobby store's definition has an `assign` action and an `assignment` field:
|
|
12
|
+
* createLobby({ …, actions: { assign: assignAction({ lobby: node.nodeIdHex() }) } });
|
|
13
|
+
* await lobby.self.act('assign', record); // the lobby node, and only it
|
|
14
|
+
* setInterval(() => lobby.host.setState(s => ({ assignment: renewLeases(s.assignment) })), leaseMs / 3);
|
|
15
|
+
*
|
|
16
|
+
* // A shard host stops when its lease stops being renewed:
|
|
17
|
+
* const lease = leaseWatch({ read: () => replica.getState().assignment?.leases[shard], leaseMs });
|
|
18
|
+
* hostNetcode({ …, live: lease.live });
|
|
19
|
+
* // A player freezes when it stops hearing the lobby:
|
|
20
|
+
* joinShards({ …, assignment: shardAssignment(record), live: leaseWatch({ read: () => replica.getState().assignment?.renewals, leaseMs }).live });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import type { ShardAssignment } from './shards.js';
|
|
24
|
+
/** One squad: its team and its players (16 hex). */
|
|
25
|
+
export interface Squad {
|
|
26
|
+
readonly team: string;
|
|
27
|
+
readonly members: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
/** One shard: the host serving it and the squads it owns. */
|
|
30
|
+
export interface ShardRecord {
|
|
31
|
+
/** The host's node id, 16 hex. The host is itself a player in some squad. */
|
|
32
|
+
readonly host: string;
|
|
33
|
+
readonly squads: readonly string[];
|
|
34
|
+
/** NPC ids this shard owns and runs the AI of (D7: fixed at spawn, see {@link npcShard}). */
|
|
35
|
+
readonly npcs?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
/** The record the lobby node writes. */
|
|
38
|
+
export interface MatchAssignment {
|
|
39
|
+
/** Raised by every reassignment; never lowered. */
|
|
40
|
+
readonly epoch: number;
|
|
41
|
+
readonly shards: Readonly<Record<string, ShardRecord>>;
|
|
42
|
+
readonly squads: Readonly<Record<string, Squad>>;
|
|
43
|
+
/**
|
|
44
|
+
* Shard → its lease counter, bumped by the lobby node every `leaseMs / 3`
|
|
45
|
+
* while it considers that host alive. A host whose counter has not moved
|
|
46
|
+
* for `leaseMs` on its own clock stops ({@link leaseWatch}).
|
|
47
|
+
*/
|
|
48
|
+
readonly leases: Readonly<Record<string, number>>;
|
|
49
|
+
/** Bumped with every renewal round: what a player watches to know it still hears the lobby. */
|
|
50
|
+
readonly renewals: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* What is wrong with `assignment`, in words; empty when it is valid.
|
|
54
|
+
*
|
|
55
|
+
* - Its shape: `squads`, `shards` and `leases` are records; `members`,
|
|
56
|
+
* `squads` and `npcs` are arrays of strings; `epoch`, every lease and
|
|
57
|
+
* `renewals` are non-negative safe integers. Names are looked up as own
|
|
58
|
+
* keys only, so a squad called `toString` is unknown, not a function.
|
|
59
|
+
* - Every squad is owned by exactly one shard, and every player is in
|
|
60
|
+
* exactly one squad — so every player is owned by exactly one shard.
|
|
61
|
+
* - **D1:** a shard owns at most one squad of each team, and never the
|
|
62
|
+
* squad its own host plays in (no "god mode for my own character").
|
|
63
|
+
* - Every host is a distinct node id, and a lease counter exists for every
|
|
64
|
+
* shard.
|
|
65
|
+
*/
|
|
66
|
+
export declare function assignmentProblems(assignment: MatchAssignment): string[];
|
|
67
|
+
/** The view `joinShards` and the shard hosts read: hosts, and owner by player. */
|
|
68
|
+
export declare function shardAssignment(assignment: MatchAssignment): ShardAssignment;
|
|
69
|
+
/**
|
|
70
|
+
* The lobby store's `assign` action: install `next` as the assignment.
|
|
71
|
+
*
|
|
72
|
+
* Refused (the action rejects, and the state is unchanged) unless the
|
|
73
|
+
* caller is the lobby node itself, `next` passes
|
|
74
|
+
* {@link assignmentProblems}, and its epoch is above the installed one.
|
|
75
|
+
* `field` names where in the store state the assignment lives (default
|
|
76
|
+
* `assignment`).
|
|
77
|
+
*/
|
|
78
|
+
export declare function assignAction<S extends object>(options: {
|
|
79
|
+
readonly lobby: string;
|
|
80
|
+
readonly field?: string;
|
|
81
|
+
}): (next: MatchAssignment, context: {
|
|
82
|
+
readonly peer: string;
|
|
83
|
+
getState(): unknown;
|
|
84
|
+
setState(update: Partial<S>): void;
|
|
85
|
+
}) => {
|
|
86
|
+
readonly epoch: number;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* One renewal round: every lease in `renew` (default: every shard) bumped,
|
|
90
|
+
* and `renewals` with it. A shard left out is a host the lobby no longer
|
|
91
|
+
* vouches for — its lease runs out on its own clock.
|
|
92
|
+
*/
|
|
93
|
+
export declare function renewLeases(assignment: MatchAssignment, renew?: Iterable<string>): MatchAssignment;
|
|
94
|
+
/**
|
|
95
|
+
* Whether the lobby may give `shard`'s entities to another host: only once
|
|
96
|
+
* `leaseMs` + `driftMarginMs` + `maxDeliveryMs` has passed since it last
|
|
97
|
+
* renewed that lease (`lastRenewedAt`: when the lobby WROTE the renewal, on
|
|
98
|
+
* its own clock).
|
|
99
|
+
*
|
|
100
|
+
* The host dates its lease from when the renewal REACHES it
|
|
101
|
+
* ({@link leaseWatch}), so a renewal delivered `d` ms late keeps it live
|
|
102
|
+
* until `lastRenewedAt + d + leaseMs` (its clock). The wait covers that
|
|
103
|
+
* only for `d <= maxDeliveryMs` and clock-rate drift within
|
|
104
|
+
* `driftMarginMs`: size `maxDeliveryMs` from the store's measured
|
|
105
|
+
* replication delay (and its reconnect timeout — a replica that resyncs
|
|
106
|
+
* after a stall delivers late). Default 0, which assumes instant delivery:
|
|
107
|
+
* not sound on a real network.
|
|
108
|
+
*/
|
|
109
|
+
export declare function mayReassign(lastRenewedAt: number, now: number, leaseMs: number, driftMarginMs: number, maxDeliveryMs?: number): boolean;
|
|
110
|
+
/** {@link leaseWatch}'s argument. */
|
|
111
|
+
export interface LeaseWatchOptions {
|
|
112
|
+
/** The counter to watch (a shard's lease, or `renewals`); `undefined` when there is none. */
|
|
113
|
+
read(): number | undefined;
|
|
114
|
+
/** How long the counter may stand still before the lease is lost, ms. */
|
|
115
|
+
readonly leaseMs: number;
|
|
116
|
+
readonly now?: () => number;
|
|
117
|
+
}
|
|
118
|
+
/** A lease, judged on this node's own clock. */
|
|
119
|
+
export interface LeaseWatch {
|
|
120
|
+
/** `true` while the counter moved within the last `leaseMs`. */
|
|
121
|
+
live(): boolean;
|
|
122
|
+
/** When the counter last moved (this clock), or `null` before it was first seen to move. */
|
|
123
|
+
lastRenewedAt(): number | null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Watch a lease counter. Live once the counter is seen to MOVE, until it
|
|
127
|
+
* stands still for `leaseMs`; live again the moment it moves again.
|
|
128
|
+
*
|
|
129
|
+
* The first value read is only a baseline, never a lease: a replica can
|
|
130
|
+
* hold a counter the lobby wrote long ago (a resync, a host that joins
|
|
131
|
+
* late), and treating its first sighting as a renewal would grant a full
|
|
132
|
+
* `leaseMs` the lobby never gave — past the point it may reassign. So a
|
|
133
|
+
* new watcher waits for one renewal round (`leaseMs / 3`) before it is live.
|
|
134
|
+
*
|
|
135
|
+
* Judged entirely on this node's clock, from when a renewal arrives, which
|
|
136
|
+
* is what lets it fence a node that cannot hear the lobby: nothing has to
|
|
137
|
+
* arrive for it to expire. A late renewal therefore extends it by its
|
|
138
|
+
* delivery delay; {@link mayReassign}'s `maxDeliveryMs` is the bound that
|
|
139
|
+
* covers that.
|
|
140
|
+
*/
|
|
141
|
+
export declare function leaseWatch(options: LeaseWatchOptions): LeaseWatch;
|
|
142
|
+
/** The team `peer` plays on in `assignment`, or `undefined`. */
|
|
143
|
+
export declare function teamOf(assignment: MatchAssignment | null | undefined, peer: string): string | undefined;
|
|
144
|
+
/**
|
|
145
|
+
* Team audiences for a store's `authorize`: a read of audience
|
|
146
|
+
* `team:<name>` is allowed only to a player on that team in the current
|
|
147
|
+
* assignment. Every other request is left to `otherwise` (default: allowed),
|
|
148
|
+
* so it composes with a game's own rules:
|
|
149
|
+
*
|
|
150
|
+
* ```ts
|
|
151
|
+
* createLobby({ …, authorize: authorizeTeams(() => lobby.host.getState().assignment) });
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
export declare function authorizeTeams(read: () => MatchAssignment | null | undefined, otherwise?: (request: {
|
|
155
|
+
readonly type: string;
|
|
156
|
+
readonly peer: string;
|
|
157
|
+
}) => boolean): (request: {
|
|
158
|
+
readonly type: string;
|
|
159
|
+
readonly peer: string;
|
|
160
|
+
readonly audience?: readonly string[];
|
|
161
|
+
}) => boolean;
|
|
162
|
+
/**
|
|
163
|
+
* D7: the shard an NPC belongs to, fixed at spawn — FNV-1a of its id over
|
|
164
|
+
* the shard names in sorted order. Every node computes the same answer, so
|
|
165
|
+
* a lobby can spawn NPCs without asking anyone.
|
|
166
|
+
*/
|
|
167
|
+
export declare function npcShard(npc: string, shards: readonly string[]): string;
|
|
168
|
+
/** `assignment` with `npcs` spawned onto shards by {@link npcShard} (added to any each shard already owns). */
|
|
169
|
+
export declare function withNpcs(assignment: MatchAssignment, npcs: readonly string[]): MatchAssignment;
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The match assignment: who hosts which shard, which squads each shard
|
|
3
|
+
* owns, and each host's lease (sharded-match plan, slice S4).
|
|
4
|
+
*
|
|
5
|
+
* It lives in the **lobby's store**, so every host and player holds a
|
|
6
|
+
* replica of the one record the lobby node wrote. It is written only by
|
|
7
|
+
* the lobby node: {@link assignAction} is the store action that installs
|
|
8
|
+
* one, and it refuses every other caller.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // The lobby store's definition has an `assign` action and an `assignment` field:
|
|
12
|
+
* createLobby({ …, actions: { assign: assignAction({ lobby: node.nodeIdHex() }) } });
|
|
13
|
+
* await lobby.self.act('assign', record); // the lobby node, and only it
|
|
14
|
+
* setInterval(() => lobby.host.setState(s => ({ assignment: renewLeases(s.assignment) })), leaseMs / 3);
|
|
15
|
+
*
|
|
16
|
+
* // A shard host stops when its lease stops being renewed:
|
|
17
|
+
* const lease = leaseWatch({ read: () => replica.getState().assignment?.leases[shard], leaseMs });
|
|
18
|
+
* hostNetcode({ …, live: lease.live });
|
|
19
|
+
* // A player freezes when it stops hearing the lobby:
|
|
20
|
+
* joinShards({ …, assignment: shardAssignment(record), live: leaseWatch({ read: () => replica.getState().assignment?.renewals, leaseMs }).live });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
const HEX16 = /^[0-9a-f]{16}$/;
|
|
24
|
+
/** `key` is `record`'s own property (never one inherited from `Object.prototype`, like `toString`). */
|
|
25
|
+
const own = (record, key) => Object.hasOwn(record, key);
|
|
26
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
27
|
+
const isCounter = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
28
|
+
/**
|
|
29
|
+
* What is wrong with `assignment`, in words; empty when it is valid.
|
|
30
|
+
*
|
|
31
|
+
* - Its shape: `squads`, `shards` and `leases` are records; `members`,
|
|
32
|
+
* `squads` and `npcs` are arrays of strings; `epoch`, every lease and
|
|
33
|
+
* `renewals` are non-negative safe integers. Names are looked up as own
|
|
34
|
+
* keys only, so a squad called `toString` is unknown, not a function.
|
|
35
|
+
* - Every squad is owned by exactly one shard, and every player is in
|
|
36
|
+
* exactly one squad — so every player is owned by exactly one shard.
|
|
37
|
+
* - **D1:** a shard owns at most one squad of each team, and never the
|
|
38
|
+
* squad its own host plays in (no "god mode for my own character").
|
|
39
|
+
* - Every host is a distinct node id, and a lease counter exists for every
|
|
40
|
+
* shard.
|
|
41
|
+
*/
|
|
42
|
+
export function assignmentProblems(assignment) {
|
|
43
|
+
const problems = [];
|
|
44
|
+
if (!isRecord(assignment))
|
|
45
|
+
return ['the assignment is not a record'];
|
|
46
|
+
if (!isCounter(assignment.epoch))
|
|
47
|
+
problems.push('epoch is not a non-negative integer');
|
|
48
|
+
if (!isCounter(assignment.renewals))
|
|
49
|
+
problems.push('renewals is not a non-negative integer');
|
|
50
|
+
const squads = isRecord(assignment.squads) ? assignment.squads : {};
|
|
51
|
+
const shards = isRecord(assignment.shards) ? assignment.shards : {};
|
|
52
|
+
const leases = isRecord(assignment.leases) ? assignment.leases : {};
|
|
53
|
+
if (!isRecord(assignment.squads))
|
|
54
|
+
problems.push('squads is not a record');
|
|
55
|
+
if (!isRecord(assignment.shards))
|
|
56
|
+
problems.push('shards is not a record');
|
|
57
|
+
if (!isRecord(assignment.leases))
|
|
58
|
+
problems.push('leases is not a record');
|
|
59
|
+
for (const [shard, lease] of Object.entries(leases)) {
|
|
60
|
+
if (!own(shards, shard))
|
|
61
|
+
problems.push(`lease for unknown shard ${shard}`);
|
|
62
|
+
else if (!isCounter(lease))
|
|
63
|
+
problems.push(`shard ${shard}'s lease is not a non-negative integer`);
|
|
64
|
+
}
|
|
65
|
+
const squadOf = new Map();
|
|
66
|
+
const teamOfSquad = new Map();
|
|
67
|
+
for (const [squad, raw] of Object.entries(squads)) {
|
|
68
|
+
if (!isRecord(raw)) {
|
|
69
|
+
problems.push(`squad ${squad} is not a record`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const { team, members } = raw;
|
|
73
|
+
if (typeof team !== 'string' || team.length === 0)
|
|
74
|
+
problems.push(`squad ${squad} has no team`);
|
|
75
|
+
else
|
|
76
|
+
teamOfSquad.set(squad, team);
|
|
77
|
+
if (!Array.isArray(members)) {
|
|
78
|
+
problems.push(`squad ${squad}: members is not an array`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const member of members) {
|
|
82
|
+
if (typeof member !== 'string' || !HEX16.test(member))
|
|
83
|
+
problems.push(`squad ${squad}: ${JSON.stringify(member)} is not a node id`);
|
|
84
|
+
const other = squadOf.get(member);
|
|
85
|
+
if (other !== undefined)
|
|
86
|
+
problems.push(`player ${member} is in two squads: ${other} and ${squad}`);
|
|
87
|
+
else
|
|
88
|
+
squadOf.set(member, squad);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const ownerOfSquad = new Map();
|
|
92
|
+
const hosts = new Map();
|
|
93
|
+
const ownerOfNpc = new Map();
|
|
94
|
+
for (const [shard, raw] of Object.entries(shards)) {
|
|
95
|
+
if (!isRecord(raw)) {
|
|
96
|
+
problems.push(`shard ${shard} is not a record`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const { host, squads: owned, npcs } = raw;
|
|
100
|
+
if (typeof host !== 'string' || !HEX16.test(host))
|
|
101
|
+
problems.push(`shard ${shard}: host ${JSON.stringify(host)} is not a node id`);
|
|
102
|
+
const twice = hosts.get(host);
|
|
103
|
+
if (twice !== undefined)
|
|
104
|
+
problems.push(`host ${String(host)} serves two shards: ${twice} and ${shard}`);
|
|
105
|
+
hosts.set(host, shard);
|
|
106
|
+
if (!own(leases, shard) || !isCounter(leases[shard]))
|
|
107
|
+
problems.push(`shard ${shard} has no lease`);
|
|
108
|
+
if (!Array.isArray(owned))
|
|
109
|
+
problems.push(`shard ${shard}: squads is not an array`);
|
|
110
|
+
const teams = new Set();
|
|
111
|
+
for (const squad of Array.isArray(owned) ? owned : []) {
|
|
112
|
+
if (typeof squad !== 'string' || !own(squads, squad) || !isRecord(squads[squad])) {
|
|
113
|
+
problems.push(`shard ${shard} owns unknown squad ${String(squad)}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const other = ownerOfSquad.get(squad);
|
|
117
|
+
if (other !== undefined)
|
|
118
|
+
problems.push(`squad ${squad} is owned by two shards: ${other} and ${shard}`);
|
|
119
|
+
else
|
|
120
|
+
ownerOfSquad.set(squad, shard);
|
|
121
|
+
const team = teamOfSquad.get(squad);
|
|
122
|
+
if (team !== undefined) {
|
|
123
|
+
if (teams.has(team))
|
|
124
|
+
problems.push(`shard ${shard} owns two squads of team ${team}`);
|
|
125
|
+
teams.add(team);
|
|
126
|
+
}
|
|
127
|
+
if (squadOf.get(host) === squad)
|
|
128
|
+
problems.push(`shard ${shard} owns its own host's squad ${squad}`);
|
|
129
|
+
}
|
|
130
|
+
if (npcs !== undefined && !Array.isArray(npcs))
|
|
131
|
+
problems.push(`shard ${shard}: npcs is not an array`);
|
|
132
|
+
for (const npc of Array.isArray(npcs) ? npcs : []) {
|
|
133
|
+
if (typeof npc !== 'string' || npc.length === 0) {
|
|
134
|
+
problems.push(`shard ${shard}: npc ${JSON.stringify(npc)} is not an id`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const other = ownerOfNpc.get(npc);
|
|
138
|
+
if (other !== undefined)
|
|
139
|
+
problems.push(`npc ${npc} is owned by two shards: ${other} and ${shard}`);
|
|
140
|
+
else
|
|
141
|
+
ownerOfNpc.set(npc, shard);
|
|
142
|
+
if (squadOf.has(npc))
|
|
143
|
+
problems.push(`npc ${npc} has a player's id`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const squad of Object.keys(squads)) {
|
|
147
|
+
if (!ownerOfSquad.has(squad))
|
|
148
|
+
problems.push(`squad ${squad} is owned by no shard`);
|
|
149
|
+
}
|
|
150
|
+
return problems;
|
|
151
|
+
}
|
|
152
|
+
/** The view `joinShards` and the shard hosts read: hosts, and owner by player. */
|
|
153
|
+
export function shardAssignment(assignment) {
|
|
154
|
+
const owner = {};
|
|
155
|
+
for (const [shard, { squads, npcs }] of Object.entries(assignment.shards)) {
|
|
156
|
+
for (const squad of squads) {
|
|
157
|
+
const record = Object.hasOwn(assignment.squads, squad) ? assignment.squads[squad] : undefined;
|
|
158
|
+
for (const member of record?.members ?? [])
|
|
159
|
+
owner[member] = shard;
|
|
160
|
+
}
|
|
161
|
+
for (const npc of npcs ?? [])
|
|
162
|
+
owner[npc] = shard;
|
|
163
|
+
}
|
|
164
|
+
const hosts = {};
|
|
165
|
+
for (const [shard, { host }] of Object.entries(assignment.shards))
|
|
166
|
+
hosts[shard] = host;
|
|
167
|
+
return { epoch: assignment.epoch, hosts, owner };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The lobby store's `assign` action: install `next` as the assignment.
|
|
171
|
+
*
|
|
172
|
+
* Refused (the action rejects, and the state is unchanged) unless the
|
|
173
|
+
* caller is the lobby node itself, `next` passes
|
|
174
|
+
* {@link assignmentProblems}, and its epoch is above the installed one.
|
|
175
|
+
* `field` names where in the store state the assignment lives (default
|
|
176
|
+
* `assignment`).
|
|
177
|
+
*/
|
|
178
|
+
export function assignAction(options) {
|
|
179
|
+
const field = options.field ?? 'assignment';
|
|
180
|
+
return (next, context) => {
|
|
181
|
+
if (context.peer !== options.lobby)
|
|
182
|
+
throw new Error('only the lobby node writes the assignment');
|
|
183
|
+
const problems = assignmentProblems(next);
|
|
184
|
+
if (problems.length > 0)
|
|
185
|
+
throw new Error(`invalid assignment: ${problems.join('; ')}`);
|
|
186
|
+
const current = context.getState()[field];
|
|
187
|
+
if (current !== null && current !== undefined && next.epoch <= current.epoch) {
|
|
188
|
+
throw new Error(`epoch ${next.epoch} is not above the installed ${current.epoch}`);
|
|
189
|
+
}
|
|
190
|
+
context.setState({ [field]: next });
|
|
191
|
+
return { epoch: next.epoch };
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* One renewal round: every lease in `renew` (default: every shard) bumped,
|
|
196
|
+
* and `renewals` with it. A shard left out is a host the lobby no longer
|
|
197
|
+
* vouches for — its lease runs out on its own clock.
|
|
198
|
+
*/
|
|
199
|
+
export function renewLeases(assignment, renew) {
|
|
200
|
+
const leases = { ...assignment.leases };
|
|
201
|
+
for (const shard of renew ?? Object.keys(assignment.shards)) {
|
|
202
|
+
if (shard in leases)
|
|
203
|
+
leases[shard] = leases[shard] + 1;
|
|
204
|
+
}
|
|
205
|
+
return { ...assignment, leases, renewals: assignment.renewals + 1 };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Whether the lobby may give `shard`'s entities to another host: only once
|
|
209
|
+
* `leaseMs` + `driftMarginMs` + `maxDeliveryMs` has passed since it last
|
|
210
|
+
* renewed that lease (`lastRenewedAt`: when the lobby WROTE the renewal, on
|
|
211
|
+
* its own clock).
|
|
212
|
+
*
|
|
213
|
+
* The host dates its lease from when the renewal REACHES it
|
|
214
|
+
* ({@link leaseWatch}), so a renewal delivered `d` ms late keeps it live
|
|
215
|
+
* until `lastRenewedAt + d + leaseMs` (its clock). The wait covers that
|
|
216
|
+
* only for `d <= maxDeliveryMs` and clock-rate drift within
|
|
217
|
+
* `driftMarginMs`: size `maxDeliveryMs` from the store's measured
|
|
218
|
+
* replication delay (and its reconnect timeout — a replica that resyncs
|
|
219
|
+
* after a stall delivers late). Default 0, which assumes instant delivery:
|
|
220
|
+
* not sound on a real network.
|
|
221
|
+
*/
|
|
222
|
+
export function mayReassign(lastRenewedAt, now, leaseMs, driftMarginMs, maxDeliveryMs = 0) {
|
|
223
|
+
return now - lastRenewedAt >= leaseMs + driftMarginMs + Math.max(0, maxDeliveryMs);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Watch a lease counter. Live once the counter is seen to MOVE, until it
|
|
227
|
+
* stands still for `leaseMs`; live again the moment it moves again.
|
|
228
|
+
*
|
|
229
|
+
* The first value read is only a baseline, never a lease: a replica can
|
|
230
|
+
* hold a counter the lobby wrote long ago (a resync, a host that joins
|
|
231
|
+
* late), and treating its first sighting as a renewal would grant a full
|
|
232
|
+
* `leaseMs` the lobby never gave — past the point it may reassign. So a
|
|
233
|
+
* new watcher waits for one renewal round (`leaseMs / 3`) before it is live.
|
|
234
|
+
*
|
|
235
|
+
* Judged entirely on this node's clock, from when a renewal arrives, which
|
|
236
|
+
* is what lets it fence a node that cannot hear the lobby: nothing has to
|
|
237
|
+
* arrive for it to expire. A late renewal therefore extends it by its
|
|
238
|
+
* delivery delay; {@link mayReassign}'s `maxDeliveryMs` is the bound that
|
|
239
|
+
* covers that.
|
|
240
|
+
*/
|
|
241
|
+
export function leaseWatch(options) {
|
|
242
|
+
const now = options.now ?? (() => Date.now());
|
|
243
|
+
let seen;
|
|
244
|
+
let baseline = true;
|
|
245
|
+
let movedAt = null;
|
|
246
|
+
const look = () => {
|
|
247
|
+
const value = options.read();
|
|
248
|
+
if (value === undefined || value === seen)
|
|
249
|
+
return;
|
|
250
|
+
seen = value;
|
|
251
|
+
if (baseline) {
|
|
252
|
+
baseline = false;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
movedAt = now();
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
live() {
|
|
259
|
+
look();
|
|
260
|
+
return movedAt !== null && now() - movedAt < options.leaseMs;
|
|
261
|
+
},
|
|
262
|
+
lastRenewedAt() {
|
|
263
|
+
look();
|
|
264
|
+
return movedAt;
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** The team `peer` plays on in `assignment`, or `undefined`. */
|
|
269
|
+
export function teamOf(assignment, peer) {
|
|
270
|
+
if (assignment === null || assignment === undefined)
|
|
271
|
+
return undefined;
|
|
272
|
+
for (const { team, members } of Object.values(assignment.squads))
|
|
273
|
+
if (members.includes(peer))
|
|
274
|
+
return team;
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Team audiences for a store's `authorize`: a read of audience
|
|
279
|
+
* `team:<name>` is allowed only to a player on that team in the current
|
|
280
|
+
* assignment. Every other request is left to `otherwise` (default: allowed),
|
|
281
|
+
* so it composes with a game's own rules:
|
|
282
|
+
*
|
|
283
|
+
* ```ts
|
|
284
|
+
* createLobby({ …, authorize: authorizeTeams(() => lobby.host.getState().assignment) });
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
export function authorizeTeams(read, otherwise = () => true) {
|
|
288
|
+
return request => {
|
|
289
|
+
if (request.type === 'read') {
|
|
290
|
+
for (const audience of request.audience ?? []) {
|
|
291
|
+
if (audience.startsWith('team:') && teamOf(read(), request.peer) !== audience.slice('team:'.length))
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return otherwise(request);
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* D7: the shard an NPC belongs to, fixed at spawn — FNV-1a of its id over
|
|
300
|
+
* the shard names in sorted order. Every node computes the same answer, so
|
|
301
|
+
* a lobby can spawn NPCs without asking anyone.
|
|
302
|
+
*/
|
|
303
|
+
export function npcShard(npc, shards) {
|
|
304
|
+
if (shards.length === 0)
|
|
305
|
+
throw new RangeError('no shards to place an NPC on');
|
|
306
|
+
let hash = 0x811c9dc5;
|
|
307
|
+
for (let i = 0; i < npc.length; i += 1)
|
|
308
|
+
hash = Math.imul(hash ^ npc.charCodeAt(i), 0x01000193);
|
|
309
|
+
return [...shards].sort()[(hash >>> 0) % shards.length];
|
|
310
|
+
}
|
|
311
|
+
/** `assignment` with `npcs` spawned onto shards by {@link npcShard} (added to any each shard already owns). */
|
|
312
|
+
export function withNpcs(assignment, npcs) {
|
|
313
|
+
const names = Object.keys(assignment.shards);
|
|
314
|
+
const shards = { ...assignment.shards };
|
|
315
|
+
for (const npc of npcs) {
|
|
316
|
+
const shard = npcShard(npc, names);
|
|
317
|
+
const record = shards[shard];
|
|
318
|
+
shards[shard] = { ...record, npcs: [...(record.npcs ?? []), npc] };
|
|
319
|
+
}
|
|
320
|
+
return { ...assignment, shards };
|
|
321
|
+
}
|
package/dist/netcode/client.d.ts
CHANGED
|
@@ -16,7 +16,27 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { type ClockEstimate } from './clock.js';
|
|
18
18
|
import { type Interpolator } from './interpolate.js';
|
|
19
|
-
import { type NetcodeTransport, type Now } from './wire.js';
|
|
19
|
+
import { type NetcodeTransport, type Now, type WireInput } from './wire.js';
|
|
20
|
+
import { type EntityCodec } from './codec.js';
|
|
21
|
+
/** Why {@link NetcodeClient.ready} gave up. */
|
|
22
|
+
export type NetcodeErrorKind =
|
|
23
|
+
/**
|
|
24
|
+
* This node holds no announcement from the host, so there is no key to
|
|
25
|
+
* reach it with. The host must announce (`createLobby` does), and this
|
|
26
|
+
* node must have heard it: query the host's tag before joining.
|
|
27
|
+
*/
|
|
28
|
+
'no-announcement'
|
|
29
|
+
/** The host was found, but the stream to it would not open. */
|
|
30
|
+
| 'unreachable'
|
|
31
|
+
/** The stream opened, and no snapshot came within the deadline. */
|
|
32
|
+
| 'timeout'
|
|
33
|
+
/** {@link NetcodeClient.close} was called first. */
|
|
34
|
+
| 'closed';
|
|
35
|
+
/** A typed join failure from {@link NetcodeClient.ready}. */
|
|
36
|
+
export declare class NetcodeError extends Error {
|
|
37
|
+
readonly kind: NetcodeErrorKind;
|
|
38
|
+
constructor(kind: NetcodeErrorKind, message: string);
|
|
39
|
+
}
|
|
20
40
|
/** Your own entity, predicted locally. */
|
|
21
41
|
export interface LocalEntity<E, I> {
|
|
22
42
|
/** Its id in the host's snapshots (usually your node id). */
|
|
@@ -35,7 +55,11 @@ export interface JoinNetcodeOptions<E, I> {
|
|
|
35
55
|
* — at least two snapshot intervals, so one lost snapshot does not
|
|
36
56
|
* stall them. */
|
|
37
57
|
readonly interpolationDelayMs?: number;
|
|
38
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Blend two states of an entity. Default: the codec's `interpolate` when
|
|
60
|
+
* a `codec` is given (its `angle()` fields turn the short way), otherwise
|
|
61
|
+
* {@link lerpNumbers}. Also blends a correction in.
|
|
62
|
+
*/
|
|
39
63
|
readonly interpolate?: Interpolator<E>;
|
|
40
64
|
/**
|
|
41
65
|
* When the newest snapshot is older than the render time (a late or lost
|
|
@@ -49,8 +73,18 @@ export interface JoinNetcodeOptions<E, I> {
|
|
|
49
73
|
readonly local?: LocalEntity<E, I>;
|
|
50
74
|
/** Unacknowledged inputs repeated in each input frame. Default 8. */
|
|
51
75
|
readonly inputRedundancy?: number;
|
|
52
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Clock pings per second until the clock estimate has settled (8
|
|
78
|
+
* samples). Default 4. Also the rate the join, the interest set and
|
|
79
|
+
* unacknowledged inputs are repeated at, which does not slow down.
|
|
80
|
+
*/
|
|
53
81
|
readonly pingRate?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Clock pings per second once the estimate has settled. Default 1:
|
|
84
|
+
* clocks drift slowly, and every pong the host sends pays a packet's
|
|
85
|
+
* fixed transport overhead (S0b measured ~160–170 B per frame).
|
|
86
|
+
*/
|
|
87
|
+
readonly settledPingRate?: number;
|
|
54
88
|
/**
|
|
55
89
|
* The interest keys to receive (the host's `interest` keys entities; an
|
|
56
90
|
* entity keyed `null` always arrives). Omit to receive everything the host
|
|
@@ -66,8 +100,44 @@ export interface JoinNetcodeOptions<E, I> {
|
|
|
66
100
|
* controls; it only closes the gap.
|
|
67
101
|
*/
|
|
68
102
|
readonly correctionSmoothingMs?: number;
|
|
103
|
+
/** How long {@link NetcodeClient.ready} waits for the first snapshot, ms. Default 15 000. */
|
|
104
|
+
readonly joinTimeoutMs?: number;
|
|
105
|
+
/**
|
|
106
|
+
* After an attempt to go direct ended with the pair relayed
|
|
107
|
+
* (`iceTimeout`, `udpBlocked`, …), or after `connectPeer` rejected, the
|
|
108
|
+
* least time before another, ms. Default 30 000. Until then a reopened
|
|
109
|
+
* stream rides the session there is, which is a supported outcome, not a
|
|
110
|
+
* failure.
|
|
111
|
+
*/
|
|
112
|
+
readonly directRetryMs?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Compact snapshots (`codec.ts`): the same codec the host uses. With a
|
|
115
|
+
* matching fingerprint the host sends binary snapshots; otherwise JSON,
|
|
116
|
+
* which this client still reads.
|
|
117
|
+
*/
|
|
118
|
+
readonly codec?: EntityCodec<E>;
|
|
119
|
+
/**
|
|
120
|
+
* Sharded matches: the assignment epoch this player has installed, read
|
|
121
|
+
* at each send. Input frames carry it (`ep`), and a host at another
|
|
122
|
+
* epoch drops them. `joinShards` supplies it.
|
|
123
|
+
*/
|
|
124
|
+
epoch?(): number;
|
|
125
|
+
/**
|
|
126
|
+
* The input sequence and the unacknowledged inputs, when they must
|
|
127
|
+
* outlive this client: `joinShards` shares one across every shard, so a
|
|
128
|
+
* player moved to another host carries on at the next `seq` and re-sends
|
|
129
|
+
* what the old host never acknowledged. Default: this client's own.
|
|
130
|
+
*/
|
|
131
|
+
readonly sequencer?: InputSequencer<I>;
|
|
69
132
|
readonly now?: Now;
|
|
70
133
|
}
|
|
134
|
+
/** The input sequence and the inputs not yet acknowledged, shareable between clients. */
|
|
135
|
+
export interface InputSequencer<I> {
|
|
136
|
+
seq: number;
|
|
137
|
+
pending: WireInput<I>[];
|
|
138
|
+
}
|
|
139
|
+
/** A fresh {@link InputSequencer}: `seq` 0, nothing pending. */
|
|
140
|
+
export declare function createInputSequencer<I>(): InputSequencer<I>;
|
|
71
141
|
/** What {@link NetcodeClient.stats} reports. */
|
|
72
142
|
export interface NetcodeStats {
|
|
73
143
|
readonly clock: ClockEstimate | null;
|
|
@@ -80,6 +150,25 @@ export interface NetcodeStats {
|
|
|
80
150
|
readonly corrections: number;
|
|
81
151
|
/** Chunked snapshots finished with chunks missing (their entities carried over). */
|
|
82
152
|
readonly partialSnapshots: number;
|
|
153
|
+
/** Snapshot frames that arrived binary (a codec negotiated). */
|
|
154
|
+
readonly binaryFrames: number;
|
|
155
|
+
/** Entities left out of a binary snapshot because their index had no name yet (asked for again). */
|
|
156
|
+
readonly unnamedEntities: number;
|
|
157
|
+
/**
|
|
158
|
+
* The latest `connectPeer` outcome for the host (`'direct'`,
|
|
159
|
+
* `'iceTimeout'`, `'noAnnouncement'`, …), or `null` before the first
|
|
160
|
+
* attempt or on a transport without `connectPeer`.
|
|
161
|
+
*/
|
|
162
|
+
readonly connectOutcome: string | null;
|
|
163
|
+
/** The last error opening the stream to the host, or `null`. */
|
|
164
|
+
readonly lastError: string | null;
|
|
165
|
+
/**
|
|
166
|
+
* The assignment epoch the newest snapshot was sent under, or `null`
|
|
167
|
+
* before one arrives or from a host that stamps none.
|
|
168
|
+
*/
|
|
169
|
+
readonly epoch: number | null;
|
|
170
|
+
/** When (this client's `now`) the last snapshot frame arrived, or `null` before one. */
|
|
171
|
+
readonly lastSnapshotAt: number | null;
|
|
83
172
|
}
|
|
84
173
|
/** The running client. */
|
|
85
174
|
export interface NetcodeClient<E, I> {
|
|
@@ -93,8 +182,41 @@ export interface NetcodeClient<E, I> {
|
|
|
93
182
|
* Throws `RangeError` over 256 keys or for a key over 64 characters.
|
|
94
183
|
*/
|
|
95
184
|
setInterest(keys: readonly string[]): void;
|
|
185
|
+
/**
|
|
186
|
+
* Resolves at the first snapshot from the host. Rejects with a
|
|
187
|
+
* {@link NetcodeError} if none came within `joinTimeoutMs` — naming why
|
|
188
|
+
* (`no-announcement`, `unreachable`, `timeout`) — or on `close()`. The
|
|
189
|
+
* client keeps trying either way: a late announcement still joins it.
|
|
190
|
+
*/
|
|
191
|
+
ready(): Promise<void>;
|
|
96
192
|
/** The host clock, as estimated. `null` before the first pong. */
|
|
97
193
|
hostNow(): number | null;
|
|
194
|
+
/**
|
|
195
|
+
* The host's entities as of host time `hostTime`, from the snapshots this
|
|
196
|
+
* client holds, and the gap between the two that bracket it — or `null`
|
|
197
|
+
* when no two do (too old, or newer than the newest). History, not a
|
|
198
|
+
* render: never extrapolated, never predicted, and an entity in only one
|
|
199
|
+
* of the two snapshots is left out (at a snapshot's exact time, that snapshot is
|
|
200
|
+
* used whole).
|
|
201
|
+
*
|
|
202
|
+
* What a shard host checks a hit claim against: where another host's
|
|
203
|
+
* entity was at that host's time (sharded-match plan, "Clocks and hit
|
|
204
|
+
* claims").
|
|
205
|
+
*/
|
|
206
|
+
sampleAt(hostTime: number): {
|
|
207
|
+
readonly entities: Readonly<Record<string, E>>;
|
|
208
|
+
readonly gapMs: number;
|
|
209
|
+
} | null;
|
|
210
|
+
/**
|
|
211
|
+
* The newest snapshot this client holds, as the host sent it (not
|
|
212
|
+
* interpolated, not predicted), or `null`. What a shard host restores a
|
|
213
|
+
* crashed host's entities from: the last state it observed.
|
|
214
|
+
*/
|
|
215
|
+
latest(): {
|
|
216
|
+
readonly time: number;
|
|
217
|
+
readonly tick: number;
|
|
218
|
+
readonly entities: Readonly<Record<string, E>>;
|
|
219
|
+
} | null;
|
|
98
220
|
stats(): NetcodeStats;
|
|
99
221
|
close(): void;
|
|
100
222
|
}
|