@aethervtt/shard-net 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +102 -2
- package/dist/client.d.ts +76 -0
- package/dist/client.js +527 -0
- package/dist/components.d.ts +72 -0
- package/dist/components.js +71 -0
- package/dist/conditions.d.ts +29 -0
- package/dist/conditions.js +128 -0
- package/dist/host.d.ts +38 -0
- package/dist/host.js +55 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +110 -0
- package/dist/interest.d.ts +67 -0
- package/dist/interest.js +277 -0
- package/dist/loopback.d.ts +12 -0
- package/dist/loopback.js +103 -0
- package/dist/plugin.d.ts +101 -0
- package/dist/plugin.js +373 -0
- package/dist/registry.d.ts +53 -0
- package/dist/registry.js +44 -0
- package/dist/server.d.ts +239 -0
- package/dist/server.js +1002 -0
- package/dist/testing.d.ts +60 -0
- package/dist/testing.js +200 -0
- package/dist/transport.d.ts +79 -0
- package/dist/transport.js +22 -0
- package/dist/vec.d.ts +13 -0
- package/dist/vec.js +27 -0
- package/dist/wire.d.ts +60 -0
- package/dist/wire.js +141 -0
- package/package.json +37 -4
package/dist/loopback.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
class LoopbackEnd {
|
|
3
|
+
peer;
|
|
4
|
+
other;
|
|
5
|
+
messageFns = [];
|
|
6
|
+
closeFns = [];
|
|
7
|
+
counters = { rtt: 0, sentBytes: 0, receivedBytes: 0, lost: 0 };
|
|
8
|
+
open = true;
|
|
9
|
+
maxMessage;
|
|
10
|
+
constructor(peer, maxMessage) {
|
|
11
|
+
this.peer = peer;
|
|
12
|
+
this.maxMessage = maxMessage;
|
|
13
|
+
}
|
|
14
|
+
send(channel, bytes) {
|
|
15
|
+
if (!this.open) return;
|
|
16
|
+
if (bytes.length > this.maxMessage) throw tooLarge(bytes.length, this.maxMessage);
|
|
17
|
+
this.counters.sentBytes += bytes.length;
|
|
18
|
+
this.other?.receive(channel, bytes.slice());
|
|
19
|
+
}
|
|
20
|
+
receive(channel, bytes) {
|
|
21
|
+
if (!this.open) return;
|
|
22
|
+
this.counters.receivedBytes += bytes.length;
|
|
23
|
+
for (const fn of this.messageFns) fn(channel, bytes);
|
|
24
|
+
}
|
|
25
|
+
onMessage(fn) {
|
|
26
|
+
this.messageFns.push(fn);
|
|
27
|
+
return () => remove(this.messageFns, fn);
|
|
28
|
+
}
|
|
29
|
+
onClose(fn) {
|
|
30
|
+
this.closeFns.push(fn);
|
|
31
|
+
return () => remove(this.closeFns, fn);
|
|
32
|
+
}
|
|
33
|
+
close(reason) {
|
|
34
|
+
if (!this.open) return;
|
|
35
|
+
this.shut(void 0);
|
|
36
|
+
this.other?.shut(
|
|
37
|
+
new ShardError("net/transport-closed", reason ?? "The other side closed the connection")
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
shut(reason) {
|
|
41
|
+
if (!this.open) return;
|
|
42
|
+
this.open = false;
|
|
43
|
+
for (const fn of [...this.closeFns]) fn(reason);
|
|
44
|
+
}
|
|
45
|
+
stats() {
|
|
46
|
+
return { ...this.counters };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function remove(list, item) {
|
|
50
|
+
const i = list.indexOf(item);
|
|
51
|
+
if (i !== -1) list.splice(i, 1);
|
|
52
|
+
}
|
|
53
|
+
function tooLarge(size, max) {
|
|
54
|
+
return new ShardError("net/message-too-large", `A ${size}-byte message is over ${max} bytes`, {
|
|
55
|
+
hint: "Reliable messages are split automatically; keep unreliable ones under maxMessage."
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function loopback(options = {}) {
|
|
59
|
+
const maxMessage = options.maxMessage ?? 1 << 20;
|
|
60
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
61
|
+
let nextPeer = 1;
|
|
62
|
+
return {
|
|
63
|
+
name: "loopback",
|
|
64
|
+
caps: { unreliable: true, maxMessage },
|
|
65
|
+
async connect(address) {
|
|
66
|
+
const accept = listeners.get(address);
|
|
67
|
+
if (!accept) {
|
|
68
|
+
throw new ShardError("net/connect-failed", `Nothing listens at "${address}"`, {
|
|
69
|
+
hint: 'Start the server (role: "server") before connecting clients.'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const client = new LoopbackEnd(0, maxMessage);
|
|
73
|
+
const server = new LoopbackEnd(nextPeer++, maxMessage);
|
|
74
|
+
client.other = server;
|
|
75
|
+
server.other = client;
|
|
76
|
+
accept(server);
|
|
77
|
+
return client;
|
|
78
|
+
},
|
|
79
|
+
async listen(options2) {
|
|
80
|
+
const address = options2.address ?? "";
|
|
81
|
+
if (listeners.has(address)) {
|
|
82
|
+
throw new ShardError("net/address-in-use", `Something already listens at "${address}"`);
|
|
83
|
+
}
|
|
84
|
+
const fns = [];
|
|
85
|
+
listeners.set(address, (end) => {
|
|
86
|
+
for (const fn of fns) fn(end);
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
onConnection(fn) {
|
|
90
|
+
fns.push(fn);
|
|
91
|
+
return () => remove(fns, fn);
|
|
92
|
+
},
|
|
93
|
+
async close() {
|
|
94
|
+
listeners.delete(address);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
loopback,
|
|
102
|
+
tooLarge
|
|
103
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type Condition, type Entity, type Fields, type InferFields, type JsonObject, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import { type Plugin } from '@aethervtt/shard-runtime';
|
|
3
|
+
import { NetClient, PendingCommand } from './client.js';
|
|
4
|
+
import { type NetRoleName } from './components.js';
|
|
5
|
+
import { type InterestPolicy } from './interest.js';
|
|
6
|
+
import { type NetCommandDef, type NetEventDef, type ReplicationEntry } from './registry.js';
|
|
7
|
+
import { type AcceptResult, NetServer, type PlayerHooks, type ServerCommand } from './server.js';
|
|
8
|
+
import { type NetSessionProvider, type NetTransport, type PeerId } from './transport.js';
|
|
9
|
+
import { type Manifest } from './wire.js';
|
|
10
|
+
/** `U`: what `accept` returns as `user`; `L`: what `players.load` returns; `S`: what `capture` returns. */
|
|
11
|
+
export interface NetOptions<U = unknown, L = unknown, S = unknown> {
|
|
12
|
+
role: NetRoleName;
|
|
13
|
+
/** With `address`: the transport to listen or connect on. A server without one runs locally. */
|
|
14
|
+
transport?: NetTransport;
|
|
15
|
+
address?: string;
|
|
16
|
+
/** Instead of transport + address: a lobby or relay, and the room code to host or join. */
|
|
17
|
+
session?: NetSessionProvider;
|
|
18
|
+
code?: string;
|
|
19
|
+
/** Server: what a lobby lists for the room (a name, a mode). */
|
|
20
|
+
meta?: JsonObject;
|
|
21
|
+
/** Network ticks per second. Must divide the app's fixedHz. Default: 30, or fixedHz if lower. */
|
|
22
|
+
tickRate?: number;
|
|
23
|
+
maxPeers?: number;
|
|
24
|
+
/** Sent with hello; the server's `accept` sees it. */
|
|
25
|
+
token?: string;
|
|
26
|
+
/** Server: refuse a peer by returning an error. */
|
|
27
|
+
/**
|
|
28
|
+
* Server: admit a peer (`{ user }`, or nothing) or refuse it (a ShardError, or a throw). May be
|
|
29
|
+
* async: the peer receives nothing until it settles (0090).
|
|
30
|
+
*/
|
|
31
|
+
accept?(peer: PeerId, token: string, world: World): AcceptResult<U> | Promise<AcceptResult<U>>;
|
|
32
|
+
/** Server: load a player on join and save it on leave (0090). */
|
|
33
|
+
players?: PlayerHooks<U, L, S>;
|
|
34
|
+
/** Server: seconds `accept` and `players.load` may take together. Default 15. */
|
|
35
|
+
joinTimeout?: number;
|
|
36
|
+
replicate?: readonly ReplicationEntry[];
|
|
37
|
+
commands?: readonly NetCommandDef[];
|
|
38
|
+
events?: readonly NetEventDef[];
|
|
39
|
+
/** Server: which entities each peer receives. Default: every replicated entity. */
|
|
40
|
+
interest?: InterestPolicy;
|
|
41
|
+
/** Server: bytes of entity data per peer per network tick. Default: unlimited. */
|
|
42
|
+
budget?: {
|
|
43
|
+
bytesPerTick?: number;
|
|
44
|
+
};
|
|
45
|
+
/** Server: commands a peer may send per second. Default 30. */
|
|
46
|
+
commandsPerSecond?: number;
|
|
47
|
+
/** Client: spawns a replicated entity from its prefab (0087). Default: a bare entity. */
|
|
48
|
+
spawnPrefab?: (world: World, prefab: string) => Entity;
|
|
49
|
+
/** The clock in ms (pings, rate limits). Default: frame time. */
|
|
50
|
+
now?: () => number;
|
|
51
|
+
}
|
|
52
|
+
export interface NetSessionData {
|
|
53
|
+
readonly role: NetRoleName;
|
|
54
|
+
readonly manifest: Manifest;
|
|
55
|
+
readonly server: NetServer | undefined;
|
|
56
|
+
readonly client: NetClient | undefined;
|
|
57
|
+
readonly transport: NetTransport | undefined;
|
|
58
|
+
/** Server: the room code (or address) players join with, once listening. */
|
|
59
|
+
code: string | undefined;
|
|
60
|
+
/** Settles when the server listens or the client's connection opens (not the handshake). */
|
|
61
|
+
ready: Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
export declare const NetSession: import("@aethervtt/shard-core").ResourceDef<NetSessionData>;
|
|
64
|
+
/** The net plugin's systems, to order game systems around them. */
|
|
65
|
+
export declare const NetSystems: {
|
|
66
|
+
readonly receive: import("@aethervtt/shard-core").SystemDef<undefined>;
|
|
67
|
+
readonly interpolate: import("@aethervtt/shard-core").SystemDef<undefined>;
|
|
68
|
+
readonly send: import("@aethervtt/shard-core").SystemDef<undefined>;
|
|
69
|
+
};
|
|
70
|
+
/** Multiplayer (0086, 0087, 0089): one plugin per app, as server or client. */
|
|
71
|
+
export declare function netPlugin<U = unknown, L = unknown, S = unknown>(options: NetOptions<U, L, S>): Plugin;
|
|
72
|
+
/** Runs on the server, and in a game without networking. */
|
|
73
|
+
export declare const onServer: Condition;
|
|
74
|
+
/** Runs only on a client. */
|
|
75
|
+
export declare const onClient: Condition;
|
|
76
|
+
/** Client: after the handshake. Server: listening (always, without a transport). */
|
|
77
|
+
export declare const connected: Condition;
|
|
78
|
+
/**
|
|
79
|
+
* Server: saves a connected player now through `players.capture` and `players.store` (autosaves),
|
|
80
|
+
* without despawning anything. Resolves when the store does (0090).
|
|
81
|
+
*/
|
|
82
|
+
export declare function savePlayer(world: World, peer: PeerId): Promise<void>;
|
|
83
|
+
/** Whether this app decides the entity's state: the server always; a client for what it owns. */
|
|
84
|
+
export declare function hasAuthority(world: World, entity: Entity): boolean;
|
|
85
|
+
export declare function netSession(world: World): NetSessionData;
|
|
86
|
+
/**
|
|
87
|
+
* Sends a command to the server. On the server itself (single-player, tests) it is handled
|
|
88
|
+
* locally by the same systems. The answer arrives in the returned object's `status`.
|
|
89
|
+
*/
|
|
90
|
+
export declare function sendCommand<F extends Fields>(world: World, def: NetCommandDef<F>, data: Partial<InferFields<F>>): PendingCommand;
|
|
91
|
+
/** Server: commands of this type waiting for an answer. Call `accept()` or `reject()` on each. */
|
|
92
|
+
export declare function commands<F extends Fields>(world: World, def: NetCommandDef<F>): ServerCommand<InferFields<F>>[];
|
|
93
|
+
export interface SendEventOptions {
|
|
94
|
+
/** `all` (default), one peer, or a filter evaluated once per peer. */
|
|
95
|
+
to?: PeerId | 'all' | ((peer: PeerId) => boolean);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Server: sends an event to clients (and to this world, so local code reads the same stream).
|
|
99
|
+
* Clients read it with `ctx.reader(def.event)`.
|
|
100
|
+
*/
|
|
101
|
+
export declare function sendEvent<F extends Fields>(world: World, def: NetEventDef<F>, data: Partial<InferFields<F>>, options?: SendEventOptions): void;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import {
|
|
2
|
+
condition,
|
|
3
|
+
defineResource,
|
|
4
|
+
defineSchema,
|
|
5
|
+
defineSystem,
|
|
6
|
+
First,
|
|
7
|
+
Last,
|
|
8
|
+
PreUpdate,
|
|
9
|
+
ShardError,
|
|
10
|
+
t
|
|
11
|
+
} from "@aethervtt/shard-core";
|
|
12
|
+
import {
|
|
13
|
+
definePlugin,
|
|
14
|
+
FixedTime,
|
|
15
|
+
Time
|
|
16
|
+
} from "@aethervtt/shard-runtime";
|
|
17
|
+
import { NetClient, PendingCommand } from "./client.js";
|
|
18
|
+
import * as components from "./components.js";
|
|
19
|
+
import { NetClock, NetRole, Replicated } from "./components.js";
|
|
20
|
+
import { isConditioned } from "./conditions.js";
|
|
21
|
+
import { alwaysInterest } from "./interest.js";
|
|
22
|
+
import { replicate } from "./registry.js";
|
|
23
|
+
import {
|
|
24
|
+
NetServer
|
|
25
|
+
} from "./server.js";
|
|
26
|
+
import { directSession } from "./transport.js";
|
|
27
|
+
import { buildManifest } from "./wire.js";
|
|
28
|
+
const NetSession = defineResource("net/NetSession", {
|
|
29
|
+
description: "The running network session: role, manifest, and the server or client (0086)."
|
|
30
|
+
});
|
|
31
|
+
const receive = defineSystem({
|
|
32
|
+
name: "net/receive",
|
|
33
|
+
description: "Drains connections: handshakes, acks, commands (server); state and events (client).",
|
|
34
|
+
run(_, world) {
|
|
35
|
+
const session = world.resource(NetSession);
|
|
36
|
+
session.server?.receive();
|
|
37
|
+
session.client?.receive();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
const interpolate = defineSystem({
|
|
41
|
+
name: "net/interpolate",
|
|
42
|
+
description: "Writes interpolated values for remote entities.",
|
|
43
|
+
run(_, world) {
|
|
44
|
+
world.resource(NetSession).client?.interpolate();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const send = defineSystem({
|
|
48
|
+
name: "net/send",
|
|
49
|
+
description: "Sends state, answers and events (server) or acks and pings (client) on network ticks.",
|
|
50
|
+
run(_, world) {
|
|
51
|
+
const session = world.resource(NetSession);
|
|
52
|
+
session.server?.send();
|
|
53
|
+
session.client?.send();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
const NetSystems = { receive, interpolate, send };
|
|
57
|
+
function netPlugin(options) {
|
|
58
|
+
let session;
|
|
59
|
+
return definePlugin({
|
|
60
|
+
name: "net",
|
|
61
|
+
provides: [components],
|
|
62
|
+
build(app) {
|
|
63
|
+
const fixedHz = app.fixedHz;
|
|
64
|
+
const tickRate = options.tickRate ?? Math.min(30, fixedHz);
|
|
65
|
+
const ticksPerNet = fixedHz / tickRate;
|
|
66
|
+
if (!Number.isInteger(ticksPerNet) || ticksPerNet < 1) {
|
|
67
|
+
throw new ShardError(
|
|
68
|
+
"net/tick-rate",
|
|
69
|
+
`tickRate ${tickRate} doesn't divide fixedHz ${fixedHz}`,
|
|
70
|
+
{
|
|
71
|
+
hint: "Use a network rate that divides the fixed rate: 60 Hz takes 60, 30, 20, 15 or 10."
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const manifest = buildManifest(
|
|
76
|
+
[replicate(Replicated, { channel: "reliable" }), ...options.replicate ?? []],
|
|
77
|
+
options.commands ?? [],
|
|
78
|
+
options.events ?? []
|
|
79
|
+
);
|
|
80
|
+
const world = app.world;
|
|
81
|
+
const now = options.now ?? (() => world.resource(Time).elapsed * 1e3);
|
|
82
|
+
const transport = options.transport;
|
|
83
|
+
const maxMessage = transport?.caps.maxMessage ?? options.session?.caps?.maxMessage ?? 1 << 20;
|
|
84
|
+
world.insertResource(NetRole, {
|
|
85
|
+
role: options.role,
|
|
86
|
+
peer: 0,
|
|
87
|
+
connected: options.role === "server" && !transport && !options.session,
|
|
88
|
+
tick: 0
|
|
89
|
+
});
|
|
90
|
+
let server;
|
|
91
|
+
let client;
|
|
92
|
+
if (options.role === "server") {
|
|
93
|
+
server = new NetServer(world, {
|
|
94
|
+
manifest,
|
|
95
|
+
interest: options.interest ?? alwaysInterest(),
|
|
96
|
+
ticksPerNet,
|
|
97
|
+
maxPeers: options.maxPeers ?? 64,
|
|
98
|
+
bytesPerTick: options.budget?.bytesPerTick ?? Number.POSITIVE_INFINITY,
|
|
99
|
+
maxMessage,
|
|
100
|
+
commandsPerSecond: options.commandsPerSecond ?? 30,
|
|
101
|
+
...options.accept ? { accept: options.accept.bind(options) } : {},
|
|
102
|
+
...options.players ? { players: options.players } : {},
|
|
103
|
+
joinTimeout: (options.joinTimeout ?? 15) * 1e3,
|
|
104
|
+
now
|
|
105
|
+
});
|
|
106
|
+
world.insertResource(NetClock, { serverTick: 0, offsetTicks: 0, rtt: 0, jitter: 0 });
|
|
107
|
+
} else {
|
|
108
|
+
client = new NetClient(world, {
|
|
109
|
+
manifest,
|
|
110
|
+
ticksPerNet,
|
|
111
|
+
maxMessage,
|
|
112
|
+
token: options.token ?? "",
|
|
113
|
+
spawnPrefab: options.spawnPrefab,
|
|
114
|
+
now
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
session = {
|
|
118
|
+
role: options.role,
|
|
119
|
+
manifest,
|
|
120
|
+
server,
|
|
121
|
+
client,
|
|
122
|
+
transport,
|
|
123
|
+
code: void 0,
|
|
124
|
+
ready: Promise.resolve()
|
|
125
|
+
};
|
|
126
|
+
world.insertResource(NetSession, session);
|
|
127
|
+
app.addSystems(First, receive);
|
|
128
|
+
app.addSystems(PreUpdate, interpolate.runIf(onClient));
|
|
129
|
+
app.addSystems(Last, send);
|
|
130
|
+
app.addMethod(...netMethods);
|
|
131
|
+
},
|
|
132
|
+
ready(app) {
|
|
133
|
+
const s = session;
|
|
134
|
+
const provider = options.session ?? (options.transport ? directSession(options.transport) : void 0);
|
|
135
|
+
if (!provider) return;
|
|
136
|
+
const role = app.world.resource(NetRole);
|
|
137
|
+
if (s.server) {
|
|
138
|
+
const server = s.server;
|
|
139
|
+
s.ready = provider.host({
|
|
140
|
+
address: options.code ?? options.address ?? "",
|
|
141
|
+
maxPeers: options.maxPeers ?? 64,
|
|
142
|
+
...options.meta ? { meta: options.meta } : {}
|
|
143
|
+
}).then(({ code, listener }) => {
|
|
144
|
+
server.attach(listener);
|
|
145
|
+
s.code = code;
|
|
146
|
+
role.connected = true;
|
|
147
|
+
});
|
|
148
|
+
return s.ready;
|
|
149
|
+
}
|
|
150
|
+
const client = s.client;
|
|
151
|
+
s.ready = provider.join(options.code ?? options.address ?? "", { token: options.token ?? "" }).then((connection) => client.attach(connection));
|
|
152
|
+
s.ready.catch((err) => {
|
|
153
|
+
client.lastError = err instanceof ShardError ? err : new ShardError("net/connect-failed", `Couldn't connect: ${String(err)}`);
|
|
154
|
+
});
|
|
155
|
+
return void 0;
|
|
156
|
+
},
|
|
157
|
+
async dispose() {
|
|
158
|
+
session?.client?.dispose();
|
|
159
|
+
await session?.server?.dispose();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
function roleOf(world) {
|
|
164
|
+
return world.tryResource(NetRole)?.role;
|
|
165
|
+
}
|
|
166
|
+
const onServer = condition("net/onServer", (world) => roleOf(world) !== "client");
|
|
167
|
+
const onClient = condition("net/onClient", (world) => roleOf(world) === "client");
|
|
168
|
+
const connected = condition(
|
|
169
|
+
"net/connected",
|
|
170
|
+
(world) => world.tryResource(NetRole)?.connected ?? false
|
|
171
|
+
);
|
|
172
|
+
async function savePlayer(world, peer) {
|
|
173
|
+
const server = netSession(world).server;
|
|
174
|
+
if (!server) throw notAuthority("save players");
|
|
175
|
+
await server.save(peer);
|
|
176
|
+
}
|
|
177
|
+
function hasAuthority(world, entity) {
|
|
178
|
+
const role = world.tryResource(NetRole);
|
|
179
|
+
if (!role || role.role === "server") return true;
|
|
180
|
+
return world.tryGet(entity, Replicated)?.owner === role.peer && role.peer !== 0;
|
|
181
|
+
}
|
|
182
|
+
function netSession(world) {
|
|
183
|
+
const session = world.tryResource(NetSession);
|
|
184
|
+
if (!session) {
|
|
185
|
+
throw new ShardError("net/no-session", "Networking is not set up in this app", {
|
|
186
|
+
hint: "Add netPlugin({ role }) to the app."
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return session;
|
|
190
|
+
}
|
|
191
|
+
function sendCommand(world, def, data) {
|
|
192
|
+
const session = netSession(world);
|
|
193
|
+
if (session.client) return session.client.sendCommand(def, data);
|
|
194
|
+
const pending = new PendingCommand(def, 0);
|
|
195
|
+
const server = session.server;
|
|
196
|
+
if (server.commandIndexOf(def) === void 0) {
|
|
197
|
+
throw new ShardError("net/unknown-command", `${def.name} isn't in netPlugin({ commands })`, {
|
|
198
|
+
hint: "List every net command in the plugin options on both sides."
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
server.local(def, data, (cmd) => {
|
|
202
|
+
pending.status = cmd.status;
|
|
203
|
+
pending.error = cmd.error;
|
|
204
|
+
});
|
|
205
|
+
return pending;
|
|
206
|
+
}
|
|
207
|
+
function commands(world, def) {
|
|
208
|
+
const session = world.tryResource(NetSession);
|
|
209
|
+
if (!session) return [];
|
|
210
|
+
const server = session.server;
|
|
211
|
+
if (!server) throw notAuthority(`read ${def.name} commands`);
|
|
212
|
+
return server.pending(def);
|
|
213
|
+
}
|
|
214
|
+
function sendEvent(world, def, data, options = {}) {
|
|
215
|
+
const session = world.tryResource(NetSession);
|
|
216
|
+
if (!session) {
|
|
217
|
+
const tick = world.tryResource(FixedTime)?.tick ?? 0;
|
|
218
|
+
world.send(def.event, { data, tick });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const server = session.server;
|
|
222
|
+
if (!server) throw notAuthority(`send ${def.name}`);
|
|
223
|
+
server.sendEvent(def, data, options.to ?? "all");
|
|
224
|
+
}
|
|
225
|
+
function notAuthority(what) {
|
|
226
|
+
return new ShardError("net/not-authority", `Only the server can ${what}`, {
|
|
227
|
+
hint: "Run this in a system gated with onServer."
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const NoParams = defineSchema("net/NoParams", {});
|
|
231
|
+
const EntityParams = defineSchema("net/EntityParams", { entity: t.entity() });
|
|
232
|
+
const PeerParams = defineSchema("net/PeerParams", { peer: t.u32(), reason: t.string() });
|
|
233
|
+
const ConditionParams = defineSchema("net/ConditionParams", {
|
|
234
|
+
latency: t.f32({ default: -1 }),
|
|
235
|
+
jitter: t.f32({ default: -1 }),
|
|
236
|
+
loss: t.f32({ default: -1 }),
|
|
237
|
+
duplicate: t.f32({ default: -1 })
|
|
238
|
+
});
|
|
239
|
+
const netMethods = [
|
|
240
|
+
{
|
|
241
|
+
name: "net.describe",
|
|
242
|
+
description: "Role, peer, tick, clock, transport and the replicated types.",
|
|
243
|
+
params: NoParams,
|
|
244
|
+
handler: ({ world }) => {
|
|
245
|
+
const session = netSession(world);
|
|
246
|
+
return {
|
|
247
|
+
...world.resource(NetRole),
|
|
248
|
+
code: session.code ?? null,
|
|
249
|
+
clock: { ...world.resource(NetClock) },
|
|
250
|
+
transport: session.transport ? { name: session.transport.name, ...session.transport.caps } : null,
|
|
251
|
+
components: session.manifest.components.map((c) => ({
|
|
252
|
+
name: c.def.name,
|
|
253
|
+
mode: c.mode,
|
|
254
|
+
channel: c.channel,
|
|
255
|
+
redacted: c.visibleTo !== void 0
|
|
256
|
+
})),
|
|
257
|
+
commands: session.manifest.commands.map((c) => c.name),
|
|
258
|
+
events: session.manifest.events.map((e) => e.name),
|
|
259
|
+
lastError: session.client?.lastError?.toJSON() ?? null
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "net.peers",
|
|
265
|
+
description: "Server: connected peers with RTT and traffic counters.",
|
|
266
|
+
params: NoParams,
|
|
267
|
+
handler: ({ world }) => {
|
|
268
|
+
const server = netSession(world).server;
|
|
269
|
+
if (!server) return [];
|
|
270
|
+
return [...server.peers.values()].map((p) => ({
|
|
271
|
+
id: p.info.id,
|
|
272
|
+
state: p.info.state,
|
|
273
|
+
rtt: p.info.rtt,
|
|
274
|
+
user: p.info.user ?? null,
|
|
275
|
+
joinedTick: p.info.joinedTick,
|
|
276
|
+
known: p.known.size,
|
|
277
|
+
relevant: p.relevant.size,
|
|
278
|
+
stats: p.stats
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: "net.stats",
|
|
284
|
+
description: "Server: bytes per peer and per component, deferred entities, resends.",
|
|
285
|
+
params: NoParams,
|
|
286
|
+
handler: ({ world }) => {
|
|
287
|
+
const server = netSession(world).server;
|
|
288
|
+
if (!server) return {};
|
|
289
|
+
return Object.fromEntries([...server.peers.values()].map((p) => [p.id, p.stats]));
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "net.inspect",
|
|
294
|
+
description: "Server: an entity's net id, owner, and per peer relevance and redaction.",
|
|
295
|
+
params: EntityParams,
|
|
296
|
+
handler: ({ world }, params) => {
|
|
297
|
+
const server = netSession(world).server;
|
|
298
|
+
if (!server) throw notAuthority("inspect relevance");
|
|
299
|
+
return server.inspect(params.entity);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: "net.relevance",
|
|
304
|
+
description: "Server: the entities one peer receives now.",
|
|
305
|
+
params: PeerParams,
|
|
306
|
+
handler: ({ world }, params) => {
|
|
307
|
+
const peer = netSession(world).server?.peers.get(params.peer);
|
|
308
|
+
return peer ? [...peer.relevant.entities] : [];
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: "net.commands",
|
|
313
|
+
description: "Server: recent commands with status and error code.",
|
|
314
|
+
params: NoParams,
|
|
315
|
+
handler: ({ world }) => (netSession(world).server?.recentCommands ?? []).map((c) => ({
|
|
316
|
+
name: c.def.name,
|
|
317
|
+
peer: c.peer,
|
|
318
|
+
tick: c.arrivedTick,
|
|
319
|
+
status: c.status,
|
|
320
|
+
code: c.error?.code ?? null
|
|
321
|
+
}))
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
name: "net.events",
|
|
325
|
+
description: "Server: recent events and their recipients.",
|
|
326
|
+
params: NoParams,
|
|
327
|
+
handler: ({ world }) => netSession(world).server?.recentEvents ?? []
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: "net.conditions",
|
|
331
|
+
description: "Reads, and with params sets, simulated latency/jitter/loss (negative keeps).",
|
|
332
|
+
params: ConditionParams,
|
|
333
|
+
handler: ({ world }, params) => {
|
|
334
|
+
const transport = netSession(world).transport;
|
|
335
|
+
if (!transport || !isConditioned(transport)) {
|
|
336
|
+
throw new ShardError("net/no-conditions", "This transport has no simulated conditions", {
|
|
337
|
+
hint: "Wrap the transport with simulateConditions()."
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const next = {};
|
|
341
|
+
for (const key of ["latency", "jitter", "loss", "duplicate"]) {
|
|
342
|
+
const v = params[key];
|
|
343
|
+
if (v >= 0) next[key] = v;
|
|
344
|
+
}
|
|
345
|
+
transport.setConditions(next);
|
|
346
|
+
return transport.conditions;
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
name: "net.disconnect",
|
|
351
|
+
description: "Server: disconnects a peer.",
|
|
352
|
+
params: PeerParams,
|
|
353
|
+
handler: ({ world }, params) => {
|
|
354
|
+
const server = netSession(world).server;
|
|
355
|
+
if (!server) throw notAuthority("disconnect peers");
|
|
356
|
+
return server.disconnect(params.peer, params.reason || void 0);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
];
|
|
360
|
+
export {
|
|
361
|
+
NetSession,
|
|
362
|
+
NetSystems,
|
|
363
|
+
commands,
|
|
364
|
+
connected,
|
|
365
|
+
hasAuthority,
|
|
366
|
+
netPlugin,
|
|
367
|
+
netSession,
|
|
368
|
+
onClient,
|
|
369
|
+
onServer,
|
|
370
|
+
savePlayer,
|
|
371
|
+
sendCommand,
|
|
372
|
+
sendEvent
|
|
373
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ComponentDef, type Entity, type EventDef, type Fields, type InferFields, type Quantize, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import type { PeerId } from './transport.js';
|
|
3
|
+
export type ReplicationMode = 'set' | 'interpolate';
|
|
4
|
+
export interface ReplicateOptions<F extends Fields = Fields> {
|
|
5
|
+
/** `set` (default) writes values as they arrive; `interpolate` renders remote entities smoothly. */
|
|
6
|
+
mode?: ReplicationMode;
|
|
7
|
+
/** `unreliable` (default) resends until acked; `reliable` arrives in order. */
|
|
8
|
+
channel?: 'reliable' | 'unreliable';
|
|
9
|
+
/** Float quantization per field. */
|
|
10
|
+
quantize?: {
|
|
11
|
+
readonly [K in keyof F]?: Quantize;
|
|
12
|
+
};
|
|
13
|
+
/** Redaction: false keeps the component off that peer's wire. */
|
|
14
|
+
visibleTo?: (world: World, peer: PeerId, entity: Entity) => boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface ReplicationEntry {
|
|
17
|
+
readonly def: ComponentDef;
|
|
18
|
+
readonly mode: ReplicationMode;
|
|
19
|
+
readonly channel: 'reliable' | 'unreliable';
|
|
20
|
+
readonly quantize: Readonly<Record<string, Quantize>>;
|
|
21
|
+
readonly visibleTo: ((world: World, peer: PeerId, entity: Entity) => boolean) | undefined;
|
|
22
|
+
}
|
|
23
|
+
/** A component to replicate, and how (0087). Pass the result to `netPlugin({ replicate })`. */
|
|
24
|
+
export declare function replicate<F extends Fields>(def: ComponentDef<F>, options?: ReplicateOptions<F>): ReplicationEntry;
|
|
25
|
+
/** Redaction helper: only the entity's owner receives the component. */
|
|
26
|
+
export declare function ownerOnly(world: World, peer: PeerId, entity: Entity): boolean;
|
|
27
|
+
/** The peer that owns a replicated entity (0 for the server), or undefined if it isn't one. */
|
|
28
|
+
export declare function ownerOf(world: World, entity: Entity): PeerId | undefined;
|
|
29
|
+
export interface NetCommandDef<F extends Fields = Fields> {
|
|
30
|
+
readonly kind: 'net-command';
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly schema: ComponentDef<F>;
|
|
33
|
+
}
|
|
34
|
+
export interface NetEventDef<F extends Fields = Fields> {
|
|
35
|
+
readonly kind: 'net-event';
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly schema: ComponentDef<F>;
|
|
38
|
+
/** Received events are sent here: read them with `ctx.reader(def.event)`. */
|
|
39
|
+
readonly event: EventDef<NetEventData<F>>;
|
|
40
|
+
}
|
|
41
|
+
export interface NetEventData<F extends Fields = Fields> {
|
|
42
|
+
readonly data: InferFields<F>;
|
|
43
|
+
/** The server tick the event was sent on. */
|
|
44
|
+
readonly tick: number;
|
|
45
|
+
}
|
|
46
|
+
/** A request a client sends and the server validates and answers (0089). */
|
|
47
|
+
export declare function defineNetCommand<const F extends Fields>(name: string, fields: F, options?: {
|
|
48
|
+
description?: string;
|
|
49
|
+
}): NetCommandDef<F>;
|
|
50
|
+
/** A message the server sends to all clients, one, or a filter (0089). */
|
|
51
|
+
export declare function defineNetEvent<const F extends Fields>(name: string, fields: F, options?: {
|
|
52
|
+
description?: string;
|
|
53
|
+
}): NetEventDef<F>;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEvent,
|
|
3
|
+
defineSchema,
|
|
4
|
+
ShardError
|
|
5
|
+
} from "@aethervtt/shard-core";
|
|
6
|
+
import { Replicated } from "./components.js";
|
|
7
|
+
function replicate(def, options = {}) {
|
|
8
|
+
if (!def.serializable && def.name !== "net/Replicated") {
|
|
9
|
+
throw new ShardError("net/not-replicable", `${def.name} is derived and can't replicate`, {
|
|
10
|
+
hint: "Replicate the components it is computed from."
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
def,
|
|
15
|
+
mode: options.mode ?? "set",
|
|
16
|
+
channel: options.channel ?? "unreliable",
|
|
17
|
+
quantize: options.quantize ?? {},
|
|
18
|
+
visibleTo: options.visibleTo
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function ownerOnly(world, peer, entity) {
|
|
22
|
+
return ownerOf(world, entity) === peer;
|
|
23
|
+
}
|
|
24
|
+
function ownerOf(world, entity) {
|
|
25
|
+
return world.tryGet(entity, Replicated)?.owner;
|
|
26
|
+
}
|
|
27
|
+
function defineNetCommand(name, fields, options = {}) {
|
|
28
|
+
return { kind: "net-command", name, schema: defineSchema(name, fields, options) };
|
|
29
|
+
}
|
|
30
|
+
function defineNetEvent(name, fields, options = {}) {
|
|
31
|
+
return {
|
|
32
|
+
kind: "net-event",
|
|
33
|
+
name,
|
|
34
|
+
schema: defineSchema(name, fields, options),
|
|
35
|
+
event: defineEvent(name)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
defineNetCommand,
|
|
40
|
+
defineNetEvent,
|
|
41
|
+
ownerOf,
|
|
42
|
+
ownerOnly,
|
|
43
|
+
replicate
|
|
44
|
+
};
|