@pylonsync/realtime 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connection.d.ts +23 -3
- package/dist/game.d.ts +2 -0
- package/dist/wire.d.ts +16 -2
- package/package.json +1 -1
- package/src/connection.test.ts +215 -1
- package/src/connection.ts +107 -7
- package/src/game.ts +5 -0
- package/src/shard-data.e2e.test.ts +331 -0
- package/src/shard-drain.e2e.test.ts +127 -0
- package/src/shard-messages.e2e.test.ts +116 -0
- package/src/shard-transfer.e2e.test.ts +163 -0
- package/src/wire.ts +17 -2
package/dist/connection.d.ts
CHANGED
|
@@ -23,9 +23,10 @@ export interface ShardConnectOptions {
|
|
|
23
23
|
* authorization hooks.
|
|
24
24
|
*
|
|
25
25
|
* Tickets expire. Pass a function to get a new one for each connection
|
|
26
|
-
* attempt, so a reconnect after the expiry still gets in.
|
|
26
|
+
* attempt, so a reconnect after the expiry still gets in. It receives the
|
|
27
|
+
* shard the client is connecting to, which changes after a transfer.
|
|
27
28
|
*/
|
|
28
|
-
ticket?: string | (() => string | Promise<string>);
|
|
29
|
+
ticket?: string | ((shardId: string) => string | Promise<string>);
|
|
29
30
|
/** Host (and port) of the Pylon server. Defaults to `window.location.host`. */
|
|
30
31
|
baseUrl?: string;
|
|
31
32
|
/**
|
|
@@ -33,7 +34,10 @@ export interface ShardConnectOptions {
|
|
|
33
34
|
* port (the dedicated port is the HTTP port + 3, e.g. 4324).
|
|
34
35
|
*/
|
|
35
36
|
wsPort?: number;
|
|
36
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Explicit WebSocket URL. Overrides baseUrl/wsPort. After a transfer its
|
|
39
|
+
* `shard` query parameter is replaced with the new shard.
|
|
40
|
+
*/
|
|
37
41
|
wsUrl?: string;
|
|
38
42
|
/** Reconnect on unexpected close (default: true). */
|
|
39
43
|
autoReconnect?: boolean;
|
|
@@ -54,6 +58,16 @@ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
|
|
|
54
58
|
onSnapshot: (fn: (snapshot: TSnapshot, tick: number, ack: number) => void) => void;
|
|
55
59
|
/** Called when the shard refuses an input (see `ShardInputRejection.code`). */
|
|
56
60
|
onInputRejected: (fn: (rejection: ShardInputRejection) => void) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Called when the server moved this subscriber to another shard (a zone
|
|
63
|
+
* line, a dungeon). The client reconnects there on its own, with the
|
|
64
|
+
* ticket the server sent; `shardId` changes before the handlers run.
|
|
65
|
+
* When the shard itself moves to another machine (a deploy), the client
|
|
66
|
+
* reconnects the same way, and these handlers are not called.
|
|
67
|
+
*/
|
|
68
|
+
onTransfer: (fn: (shardId: string, from: string) => void) => void;
|
|
69
|
+
/** The shard the client is connected (or connecting) to. */
|
|
70
|
+
readonly shardId: string;
|
|
57
71
|
/**
|
|
58
72
|
* For a shard that replicates entities: called after each frame is
|
|
59
73
|
* applied to `entities`, with what it changed.
|
|
@@ -88,6 +102,12 @@ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
|
|
|
88
102
|
close: () => void;
|
|
89
103
|
readonly connected: boolean;
|
|
90
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* True when a shard ticket (`v1.<payload>.<signature>`, the payload
|
|
107
|
+
* base64url JSON with `exp` in Unix seconds) expires within `marginSecs`.
|
|
108
|
+
* A ticket that does not parse counts as not expired: the server decides.
|
|
109
|
+
*/
|
|
110
|
+
export declare function ticketExpired(ticket: string, nowMs?: number, marginSecs?: number): boolean;
|
|
91
111
|
/**
|
|
92
112
|
* Connect to a shard without React. Returns a client you can wire into any
|
|
93
113
|
* framework or render loop.
|
package/dist/game.d.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface ShardGame<TInput = unknown> {
|
|
|
71
71
|
predict<S>(step: (state: S, input: TInput) => S, options?: PredictorOptions): Predictor<S, TInput>;
|
|
72
72
|
onReplication(fn: (entities: EntityTable, summary: ReplicationSummary, tick: number, ack: number) => void): void;
|
|
73
73
|
onInputRejected(fn: (rejection: ShardInputRejection) => void): void;
|
|
74
|
+
/** The server moved this player to another shard; see `ShardClient.onTransfer`. */
|
|
75
|
+
onTransfer(fn: (shardId: string, from: string) => void): void;
|
|
74
76
|
onOpen(fn: () => void): void;
|
|
75
77
|
onClose(fn: () => void): void;
|
|
76
78
|
onError(fn: (err: Error) => void): void;
|
package/dist/wire.d.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* A client asks for it with `?v=2` on the shard WebSocket URL. Each server
|
|
5
5
|
* message is a binary frame with an 18-byte header:
|
|
6
6
|
*
|
|
7
|
-
* 0 1 frame kind: 1 snapshot, 2 input rejected, 3 entity replication
|
|
7
|
+
* 0 1 frame kind: 1 snapshot, 2 input rejected, 3 entity replication,
|
|
8
|
+
* 4 transfer (JSON `{ shard, ticket }`: the last frame; reconnect there)
|
|
8
9
|
* 1 1 codec: 0 JSON, 1 MessagePack, 2 bincode, 3 custom, 4 replication
|
|
9
10
|
* 2 8 tick (u64 big-endian)
|
|
10
11
|
* 10 8 ack: highest client_seq the shard processed for this subscriber (0 = none)
|
|
@@ -20,7 +21,17 @@ export declare const ShardFrameKind: {
|
|
|
20
21
|
readonly InputRejected: 2;
|
|
21
22
|
/** An entity replication frame: apply it to an `EntityTable`. */
|
|
22
23
|
readonly Replication: 3;
|
|
24
|
+
/**
|
|
25
|
+
* The subscriber moved to another shard (`ShardTransferNotice`, JSON). The
|
|
26
|
+
* last frame on the connection.
|
|
27
|
+
*/
|
|
28
|
+
readonly Transfer: 4;
|
|
23
29
|
};
|
|
30
|
+
/** Where the subscriber went: connect to `shard` with `ticket`. */
|
|
31
|
+
export interface ShardTransferNotice {
|
|
32
|
+
shard: string;
|
|
33
|
+
ticket: string;
|
|
34
|
+
}
|
|
24
35
|
export declare const ShardCodec: {
|
|
25
36
|
readonly Json: 0;
|
|
26
37
|
readonly MessagePack: 1;
|
|
@@ -39,7 +50,10 @@ export interface ShardFrame {
|
|
|
39
50
|
/** Why an input did not take effect. */
|
|
40
51
|
export interface ShardInputRejection {
|
|
41
52
|
clientSeq: number | null;
|
|
42
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* `unauthorized`, `rate_limited`, `queue_full`, `invalid`, `stopped`,
|
|
55
|
+
* `transferring`, or `apply_failed`.
|
|
56
|
+
*/
|
|
43
57
|
code: string;
|
|
44
58
|
message: string;
|
|
45
59
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.17.0",
|
|
7
7
|
"description": "Pylon realtime shard client pieces with no framework: the wire protocol, codecs, and the entity replication decoder and table.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"main": "./src/index.ts",
|
package/src/connection.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { connectShard } from "./connection";
|
|
3
|
+
import { connectShard, ticketExpired } from "./connection";
|
|
4
4
|
|
|
5
5
|
const sockets: FakeWebSocket[] = [];
|
|
6
6
|
class FakeWebSocket {
|
|
@@ -78,3 +78,217 @@ test("send returns 0 and sends nothing while the socket is not open", () => {
|
|
|
78
78
|
expect(errors[0]).toContain("not open");
|
|
79
79
|
client.close();
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
/** A version 2 frame with a JSON payload. */
|
|
83
|
+
function jsonFrame(kind: number, tick: number, body: unknown): ArrayBuffer {
|
|
84
|
+
const payload = new TextEncoder().encode(JSON.stringify(body));
|
|
85
|
+
const out = new Uint8Array(18 + payload.length);
|
|
86
|
+
const view = new DataView(out.buffer);
|
|
87
|
+
view.setUint8(0, kind);
|
|
88
|
+
view.setUint8(1, 0);
|
|
89
|
+
view.setUint32(6, tick);
|
|
90
|
+
out.set(payload, 18);
|
|
91
|
+
return out.buffer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("a transfer frame moves the client to the new shard with the ticket it carries", async () => {
|
|
95
|
+
const asked: string[] = [];
|
|
96
|
+
const client = connectShard<{ zone: string }>("west", {
|
|
97
|
+
subscriberId: "p1",
|
|
98
|
+
baseUrl: "h",
|
|
99
|
+
ticket: (shard) => {
|
|
100
|
+
asked.push(shard);
|
|
101
|
+
return `own-${shard}`;
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const moves: Array<[string, string]> = [];
|
|
105
|
+
const snaps: string[] = [];
|
|
106
|
+
client.onTransfer((to, from) => moves.push([to, from]));
|
|
107
|
+
client.onSnapshot((s) => snaps.push(s.zone));
|
|
108
|
+
await settle();
|
|
109
|
+
const first = sockets[sockets.length - 1];
|
|
110
|
+
expect(first.url).toContain("shard=west");
|
|
111
|
+
expect(first.protocols).toEqual(["ticket.own-west"]);
|
|
112
|
+
first.readyState = 1;
|
|
113
|
+
first.onopen?.();
|
|
114
|
+
first.onmessage?.({ data: jsonFrame(1, 500, { zone: "west" }) });
|
|
115
|
+
expect(client.tick).toBe(500);
|
|
116
|
+
|
|
117
|
+
// The server moves the player, then closes.
|
|
118
|
+
first.onmessage?.({ data: jsonFrame(4, 501, { shard: "east", ticket: "from-server" }) });
|
|
119
|
+
expect(client.shardId).toBe("east");
|
|
120
|
+
expect(moves).toEqual([["east", "west"]]);
|
|
121
|
+
expect(client.tick).toBe(-1);
|
|
122
|
+
first.close();
|
|
123
|
+
await settle();
|
|
124
|
+
|
|
125
|
+
// At once, to the new shard, with the ticket from the frame.
|
|
126
|
+
const second = sockets[sockets.length - 1];
|
|
127
|
+
expect(second).not.toBe(first);
|
|
128
|
+
expect(second.url).toContain("shard=east");
|
|
129
|
+
expect(second.protocols).toEqual(["ticket.from-server"]);
|
|
130
|
+
expect(delays[delays.length - 1]).toBe(0);
|
|
131
|
+
second.readyState = 1;
|
|
132
|
+
second.onopen?.();
|
|
133
|
+
// The new shard's ticks start low; the client takes them.
|
|
134
|
+
second.onmessage?.({ data: jsonFrame(1, 3, { zone: "east" }) });
|
|
135
|
+
expect(client.tick).toBe(3);
|
|
136
|
+
expect(snaps).toEqual(["west", "east"]);
|
|
137
|
+
|
|
138
|
+
// A later reconnect asks the ticket function for the new shard.
|
|
139
|
+
second.close();
|
|
140
|
+
await settle();
|
|
141
|
+
expect(asked).toEqual(["west", "east"]);
|
|
142
|
+
expect(sockets[sockets.length - 1].protocols).toEqual(["ticket.own-east"]);
|
|
143
|
+
client.close();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("an explicit wsUrl follows a transfer", async () => {
|
|
147
|
+
const client = connectShard("west", {
|
|
148
|
+
subscriberId: "p1",
|
|
149
|
+
wsUrl: "ws://h/shard?shard=west&sid=p1&v=2",
|
|
150
|
+
});
|
|
151
|
+
await settle();
|
|
152
|
+
const first = sockets[sockets.length - 1];
|
|
153
|
+
first.readyState = 1;
|
|
154
|
+
first.onopen?.();
|
|
155
|
+
first.onmessage?.({ data: jsonFrame(4, 9, { shard: "east", ticket: "t" }) });
|
|
156
|
+
first.close();
|
|
157
|
+
await settle();
|
|
158
|
+
expect(sockets[sockets.length - 1].url).toBe("ws://h/shard?shard=east&sid=p1&v=2");
|
|
159
|
+
client.close();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("after a transfer, retries keep the transfer ticket until the new shard sends a frame", async () => {
|
|
163
|
+
const client = connectShard("west", {
|
|
164
|
+
subscriberId: "p1",
|
|
165
|
+
baseUrl: "h",
|
|
166
|
+
ticket: (shard) => `own-${shard}`,
|
|
167
|
+
});
|
|
168
|
+
await settle();
|
|
169
|
+
const first = sockets[sockets.length - 1];
|
|
170
|
+
first.readyState = 1;
|
|
171
|
+
first.onopen?.();
|
|
172
|
+
first.onmessage?.({ data: jsonFrame(4, 1, { shard: "east", ticket: "from-server" }) });
|
|
173
|
+
first.close();
|
|
174
|
+
await settle();
|
|
175
|
+
// The first attempt at east fails before opening: the retry still uses
|
|
176
|
+
// the server's ticket.
|
|
177
|
+
const failed = sockets[sockets.length - 1];
|
|
178
|
+
expect(failed.protocols).toEqual(["ticket.from-server"]);
|
|
179
|
+
failed.close();
|
|
180
|
+
await settle();
|
|
181
|
+
// Opened, then closed before any frame (the shard refused the ticket, say):
|
|
182
|
+
// still the server's ticket.
|
|
183
|
+
const opened = sockets[sockets.length - 1];
|
|
184
|
+
expect(opened.protocols).toEqual(["ticket.from-server"]);
|
|
185
|
+
opened.readyState = 1;
|
|
186
|
+
opened.onopen?.();
|
|
187
|
+
opened.close();
|
|
188
|
+
await settle();
|
|
189
|
+
const answered = sockets[sockets.length - 1];
|
|
190
|
+
expect(answered.protocols).toEqual(["ticket.from-server"]);
|
|
191
|
+
// A frame from the new shard: the ticket function takes over.
|
|
192
|
+
answered.readyState = 1;
|
|
193
|
+
answered.onopen?.();
|
|
194
|
+
answered.onmessage?.({ data: jsonFrame(1, 2, { zone: "east" }) });
|
|
195
|
+
answered.close();
|
|
196
|
+
await settle();
|
|
197
|
+
expect(sockets[sockets.length - 1].protocols).toEqual(["ticket.own-east"]);
|
|
198
|
+
client.close();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a fixed ticket names the first shard and is not sent after a transfer", async () => {
|
|
202
|
+
const client = connectShard("west", { subscriberId: "p1", baseUrl: "h", ticket: "for-west" });
|
|
203
|
+
await settle();
|
|
204
|
+
const first = sockets[sockets.length - 1];
|
|
205
|
+
expect(first.protocols).toEqual(["ticket.for-west"]);
|
|
206
|
+
first.readyState = 1;
|
|
207
|
+
first.onopen?.();
|
|
208
|
+
first.onmessage?.({ data: jsonFrame(4, 1, { shard: "east", ticket: "for-east" }) });
|
|
209
|
+
first.close();
|
|
210
|
+
await settle();
|
|
211
|
+
const second = sockets[sockets.length - 1];
|
|
212
|
+
second.readyState = 1;
|
|
213
|
+
second.onopen?.();
|
|
214
|
+
second.close();
|
|
215
|
+
await settle();
|
|
216
|
+
expect(sockets[sockets.length - 1].protocols).toEqual(["ticket.for-east"]);
|
|
217
|
+
client.close();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/** A ticket shaped like the server's, expiring at `exp` (Unix seconds). */
|
|
221
|
+
function ticketWithExp(exp: number): string {
|
|
222
|
+
const payload = btoa(JSON.stringify({ shard: "east", sid: "p1", exp }))
|
|
223
|
+
.replace(/\+/g, "-")
|
|
224
|
+
.replace(/\//g, "_")
|
|
225
|
+
.replace(/=+$/, "");
|
|
226
|
+
return `v1.${payload}.sig`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
test("ticketExpired reads the ticket's exp", () => {
|
|
230
|
+
const now = 1_000_000_000_000;
|
|
231
|
+
expect(ticketExpired(ticketWithExp(now / 1000 + 600), now)).toBe(false);
|
|
232
|
+
expect(ticketExpired(ticketWithExp(now / 1000 - 1), now)).toBe(true);
|
|
233
|
+
// Within the margin counts as expired.
|
|
234
|
+
expect(ticketExpired(ticketWithExp(now / 1000 + 2), now)).toBe(true);
|
|
235
|
+
expect(ticketExpired("not-a-ticket", now)).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("an expired transfer ticket gives way to the ticket function", async () => {
|
|
239
|
+
const client = connectShard("west", {
|
|
240
|
+
subscriberId: "p1",
|
|
241
|
+
baseUrl: "h",
|
|
242
|
+
ticket: (shard) => `own-${shard}`,
|
|
243
|
+
});
|
|
244
|
+
await settle();
|
|
245
|
+
const first = sockets[sockets.length - 1];
|
|
246
|
+
first.readyState = 1;
|
|
247
|
+
first.onopen?.();
|
|
248
|
+
const stale = ticketWithExp(Math.floor(Date.now() / 1000) - 60);
|
|
249
|
+
first.onmessage?.({ data: jsonFrame(4, 1, { shard: "east", ticket: stale }) });
|
|
250
|
+
first.close();
|
|
251
|
+
await settle();
|
|
252
|
+
expect(sockets[sockets.length - 1].protocols).toEqual(["ticket.own-east"]);
|
|
253
|
+
client.close();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("an expired transfer ticket with a fixed ticket for another shard stops with an error", async () => {
|
|
257
|
+
const client = connectShard("west", { subscriberId: "p1", baseUrl: "h", ticket: "for-west" });
|
|
258
|
+
const errors: string[] = [];
|
|
259
|
+
client.onError((e) => errors.push(e.message));
|
|
260
|
+
await settle();
|
|
261
|
+
const first = sockets[sockets.length - 1];
|
|
262
|
+
first.readyState = 1;
|
|
263
|
+
first.onopen?.();
|
|
264
|
+
const stale = ticketWithExp(Math.floor(Date.now() / 1000) - 60);
|
|
265
|
+
first.onmessage?.({ data: jsonFrame(4, 1, { shard: "east", ticket: stale }) });
|
|
266
|
+
const before = sockets.length;
|
|
267
|
+
first.close();
|
|
268
|
+
await settle();
|
|
269
|
+
expect(sockets.length).toBe(before);
|
|
270
|
+
expect(errors).toEqual([
|
|
271
|
+
"the ticket for shard east expired before the client reconnected; pass a ticket function to get new ones",
|
|
272
|
+
]);
|
|
273
|
+
client.close();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("the same shard on another machine: reconnect at once, no transfer reported", async () => {
|
|
277
|
+
const client = connectShard("west", { subscriberId: "p1", baseUrl: "h", ticket: "for-west" });
|
|
278
|
+
const moves: string[] = [];
|
|
279
|
+
client.onTransfer((to) => moves.push(to));
|
|
280
|
+
await settle();
|
|
281
|
+
const first = sockets[sockets.length - 1];
|
|
282
|
+
first.readyState = 1;
|
|
283
|
+
first.onopen?.();
|
|
284
|
+
first.onmessage?.({ data: jsonFrame(4, 7, { shard: "west", ticket: "fresh-west" }) });
|
|
285
|
+
first.close();
|
|
286
|
+
await settle();
|
|
287
|
+
const second = sockets[sockets.length - 1];
|
|
288
|
+
expect(second.url).toContain("shard=west");
|
|
289
|
+
expect(second.protocols).toEqual(["ticket.fresh-west"]);
|
|
290
|
+
expect(delays[delays.length - 1]).toBe(0);
|
|
291
|
+
expect(moves).toEqual([]);
|
|
292
|
+
expect(client.shardId).toBe("west");
|
|
293
|
+
client.close();
|
|
294
|
+
});
|
package/src/connection.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
parseShardFrame,
|
|
17
17
|
type ShardInputRejection,
|
|
18
18
|
type ShardPayloadDecoder,
|
|
19
|
+
type ShardTransferNotice,
|
|
19
20
|
} from "./wire";
|
|
20
21
|
|
|
21
22
|
export interface ShardConnectOptions {
|
|
@@ -34,9 +35,10 @@ export interface ShardConnectOptions {
|
|
|
34
35
|
* authorization hooks.
|
|
35
36
|
*
|
|
36
37
|
* Tickets expire. Pass a function to get a new one for each connection
|
|
37
|
-
* attempt, so a reconnect after the expiry still gets in.
|
|
38
|
+
* attempt, so a reconnect after the expiry still gets in. It receives the
|
|
39
|
+
* shard the client is connecting to, which changes after a transfer.
|
|
38
40
|
*/
|
|
39
|
-
ticket?: string | (() => string | Promise<string>);
|
|
41
|
+
ticket?: string | ((shardId: string) => string | Promise<string>);
|
|
40
42
|
/** Host (and port) of the Pylon server. Defaults to `window.location.host`. */
|
|
41
43
|
baseUrl?: string;
|
|
42
44
|
/**
|
|
@@ -44,7 +46,10 @@ export interface ShardConnectOptions {
|
|
|
44
46
|
* port (the dedicated port is the HTTP port + 3, e.g. 4324).
|
|
45
47
|
*/
|
|
46
48
|
wsPort?: number;
|
|
47
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Explicit WebSocket URL. Overrides baseUrl/wsPort. After a transfer its
|
|
51
|
+
* `shard` query parameter is replaced with the new shard.
|
|
52
|
+
*/
|
|
48
53
|
wsUrl?: string;
|
|
49
54
|
/** Reconnect on unexpected close (default: true). */
|
|
50
55
|
autoReconnect?: boolean;
|
|
@@ -66,6 +71,16 @@ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
|
|
|
66
71
|
onSnapshot: (fn: (snapshot: TSnapshot, tick: number, ack: number) => void) => void;
|
|
67
72
|
/** Called when the shard refuses an input (see `ShardInputRejection.code`). */
|
|
68
73
|
onInputRejected: (fn: (rejection: ShardInputRejection) => void) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Called when the server moved this subscriber to another shard (a zone
|
|
76
|
+
* line, a dungeon). The client reconnects there on its own, with the
|
|
77
|
+
* ticket the server sent; `shardId` changes before the handlers run.
|
|
78
|
+
* When the shard itself moves to another machine (a deploy), the client
|
|
79
|
+
* reconnects the same way, and these handlers are not called.
|
|
80
|
+
*/
|
|
81
|
+
onTransfer: (fn: (shardId: string, from: string) => void) => void;
|
|
82
|
+
/** The shard the client is connected (or connecting) to. */
|
|
83
|
+
readonly shardId: string;
|
|
69
84
|
/**
|
|
70
85
|
* For a shard that replicates entities: called after each frame is
|
|
71
86
|
* applied to `entities`, with what it changed.
|
|
@@ -103,6 +118,25 @@ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
|
|
|
103
118
|
readonly connected: boolean;
|
|
104
119
|
}
|
|
105
120
|
|
|
121
|
+
/**
|
|
122
|
+
* True when a shard ticket (`v1.<payload>.<signature>`, the payload
|
|
123
|
+
* base64url JSON with `exp` in Unix seconds) expires within `marginSecs`.
|
|
124
|
+
* A ticket that does not parse counts as not expired: the server decides.
|
|
125
|
+
*/
|
|
126
|
+
export function ticketExpired(ticket: string, nowMs = Date.now(), marginSecs = 5): boolean {
|
|
127
|
+
const payload = ticket.split(".")[1];
|
|
128
|
+
if (!payload) return false;
|
|
129
|
+
try {
|
|
130
|
+
const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
131
|
+
const json = JSON.parse(atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="))) as {
|
|
132
|
+
exp?: unknown;
|
|
133
|
+
};
|
|
134
|
+
return typeof json.exp === "number" && json.exp * 1000 <= nowMs + marginSecs * 1000;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
106
140
|
/** Input send times kept for the round-trip estimate. */
|
|
107
141
|
const MAX_TIMED = 256;
|
|
108
142
|
|
|
@@ -115,11 +149,17 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
115
149
|
options: ShardConnectOptions,
|
|
116
150
|
): ShardClient<TSnapshot, TInput> {
|
|
117
151
|
const now = options.now ?? (() => performance.now());
|
|
152
|
+
let currentShard = shardId;
|
|
153
|
+
// The ticket a transfer frame carried. Used until the new shard sends a
|
|
154
|
+
// frame (then a ticket function takes over), and for good with a fixed
|
|
155
|
+
// `ticket`, which names the old shard.
|
|
156
|
+
let transferTicket: string | null = null;
|
|
118
157
|
let ws: WebSocket | null = null;
|
|
119
158
|
let clientSeq = 0;
|
|
120
159
|
let closed = false;
|
|
121
160
|
let connected = false;
|
|
122
161
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
162
|
+
let transferring = false;
|
|
123
163
|
let backoff = options.reconnectBackoffMs ?? 500;
|
|
124
164
|
// The shard's codec, learned from the first frame. Until then inputs go
|
|
125
165
|
// as JSON text, which every shard accepts.
|
|
@@ -133,6 +173,7 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
133
173
|
|
|
134
174
|
const snapshotHandlers: Array<(s: TSnapshot, t: number, ack: number) => void> = [];
|
|
135
175
|
const rejectionHandlers: Array<(r: ShardInputRejection) => void> = [];
|
|
176
|
+
const transferHandlers: Array<(shard: string, from: string) => void> = [];
|
|
136
177
|
const replicationHandlers: Array<
|
|
137
178
|
(entities: EntityTable, summary: ReplicationSummary, tick: number, ack: number) => void
|
|
138
179
|
> = [];
|
|
@@ -146,13 +187,18 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
146
187
|
};
|
|
147
188
|
|
|
148
189
|
const buildWsUrl = (): string => {
|
|
149
|
-
if (options.wsUrl)
|
|
190
|
+
if (options.wsUrl) {
|
|
191
|
+
if (currentShard === shardId) return options.wsUrl;
|
|
192
|
+
const url = new URL(options.wsUrl);
|
|
193
|
+
url.searchParams.set("shard", currentShard);
|
|
194
|
+
return url.toString();
|
|
195
|
+
}
|
|
150
196
|
const proto =
|
|
151
197
|
typeof window !== "undefined" && window.location.protocol === "https:" ? "wss" : "ws";
|
|
152
198
|
// Only shard id + subscriber id land in the URL: routing metadata, not
|
|
153
199
|
// credentials.
|
|
154
200
|
const params = new URLSearchParams({
|
|
155
|
-
shard:
|
|
201
|
+
shard: currentShard,
|
|
156
202
|
sid: options.subscriberId,
|
|
157
203
|
v: String(SHARD_PROTOCOL_VERSION),
|
|
158
204
|
});
|
|
@@ -188,13 +234,32 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
188
234
|
const connect = () => {
|
|
189
235
|
if (closed) return;
|
|
190
236
|
const source = options.ticket;
|
|
237
|
+
// A transfer's ticket that expired before the client got through: a
|
|
238
|
+
// ticket function gives a new one; a fixed ticket still works for the
|
|
239
|
+
// shard it names.
|
|
240
|
+
if (transferTicket !== null && ticketExpired(transferTicket)) {
|
|
241
|
+
transferTicket = null;
|
|
242
|
+
if (typeof source !== "function" && currentShard !== shardId) {
|
|
243
|
+
dispatchError(
|
|
244
|
+
new Error(
|
|
245
|
+
`the ticket for shard ${currentShard} expired before the client reconnected; pass a ticket function to get new ones`,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
closed = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (transferTicket !== null) {
|
|
253
|
+
open(transferTicket);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
191
256
|
if (typeof source !== "function") {
|
|
192
257
|
open(source);
|
|
193
258
|
return;
|
|
194
259
|
}
|
|
195
260
|
let ticket: string | Promise<string>;
|
|
196
261
|
try {
|
|
197
|
-
ticket = source();
|
|
262
|
+
ticket = source(currentShard);
|
|
198
263
|
} catch (e) {
|
|
199
264
|
dispatchError(e instanceof Error ? e : new Error(String(e)));
|
|
200
265
|
scheduleReconnect();
|
|
@@ -243,6 +308,30 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
243
308
|
const at = now();
|
|
244
309
|
try {
|
|
245
310
|
const frame = parseShardFrame(event.data);
|
|
311
|
+
if (frame.kind === ShardFrameKind.Transfer) {
|
|
312
|
+
const notice = decodeShardPayload(frame.codec, frame.payload) as ShardTransferNotice;
|
|
313
|
+
const from = currentShard;
|
|
314
|
+
currentShard = notice.shard;
|
|
315
|
+
transferTicket = notice.ticket;
|
|
316
|
+
// A new shard, or this one started on another machine (a deploy):
|
|
317
|
+
// its ticks, acks, and entities start over.
|
|
318
|
+
clock.reset();
|
|
319
|
+
entities.clear();
|
|
320
|
+
lastTick = -1;
|
|
321
|
+
lastAck = 0;
|
|
322
|
+
sentAt.clear();
|
|
323
|
+
codec = null;
|
|
324
|
+
backoff = options.reconnectBackoffMs ?? 500;
|
|
325
|
+
// The same shard on another machine is not a move for the app.
|
|
326
|
+
if (currentShard !== from) {
|
|
327
|
+
for (const h of transferHandlers) h(currentShard, from);
|
|
328
|
+
}
|
|
329
|
+
// The server closes this connection; reconnect at once.
|
|
330
|
+
transferring = true;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// The new shard answered: a ticket function gives the next tickets.
|
|
334
|
+
if (typeof options.ticket === "function") transferTicket = null;
|
|
246
335
|
if (frame.kind === ShardFrameKind.Replication || frame.kind === ShardFrameKind.Snapshot) {
|
|
247
336
|
// Only the per-tick frames: a rejection can go out before its
|
|
248
337
|
// tick's frame is built, and would make the clock run early.
|
|
@@ -287,12 +376,17 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
287
376
|
};
|
|
288
377
|
|
|
289
378
|
ws.onerror = () => {
|
|
290
|
-
dispatchError(new Error(`WebSocket error connecting to shard ${
|
|
379
|
+
dispatchError(new Error(`WebSocket error connecting to shard ${currentShard}`));
|
|
291
380
|
};
|
|
292
381
|
|
|
293
382
|
ws.onclose = () => {
|
|
294
383
|
connected = false;
|
|
295
384
|
for (const h of closeHandlers) h();
|
|
385
|
+
if (transferring && !closed) {
|
|
386
|
+
transferring = false;
|
|
387
|
+
reconnectTimer = setTimeout(connect, 0);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
296
390
|
scheduleReconnect();
|
|
297
391
|
};
|
|
298
392
|
};
|
|
@@ -303,6 +397,9 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
303
397
|
get connected() {
|
|
304
398
|
return connected;
|
|
305
399
|
},
|
|
400
|
+
get shardId() {
|
|
401
|
+
return currentShard;
|
|
402
|
+
},
|
|
306
403
|
get entities() {
|
|
307
404
|
return entities;
|
|
308
405
|
},
|
|
@@ -324,6 +421,9 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
|
|
|
324
421
|
onInputRejected(fn) {
|
|
325
422
|
rejectionHandlers.push(fn);
|
|
326
423
|
},
|
|
424
|
+
onTransfer(fn) {
|
|
425
|
+
transferHandlers.push(fn);
|
|
426
|
+
},
|
|
327
427
|
onReplication(fn) {
|
|
328
428
|
replicationHandlers.push(fn);
|
|
329
429
|
},
|
package/src/game.ts
CHANGED
|
@@ -80,6 +80,8 @@ export interface ShardGame<TInput = unknown> {
|
|
|
80
80
|
fn: (entities: EntityTable, summary: ReplicationSummary, tick: number, ack: number) => void,
|
|
81
81
|
): void;
|
|
82
82
|
onInputRejected(fn: (rejection: ShardInputRejection) => void): void;
|
|
83
|
+
/** The server moved this player to another shard; see `ShardClient.onTransfer`. */
|
|
84
|
+
onTransfer(fn: (shardId: string, from: string) => void): void;
|
|
83
85
|
onOpen(fn: () => void): void;
|
|
84
86
|
onClose(fn: () => void): void;
|
|
85
87
|
onError(fn: (err: Error) => void): void;
|
|
@@ -104,6 +106,8 @@ export function connectShardGame<TInput = unknown>(
|
|
|
104
106
|
connection.onOpen(() => {
|
|
105
107
|
for (const p of predictors) p.reset();
|
|
106
108
|
});
|
|
109
|
+
// Another shard: its entities and ticks start over.
|
|
110
|
+
connection.onTransfer(() => interpolator.clear());
|
|
107
111
|
|
|
108
112
|
return {
|
|
109
113
|
connection,
|
|
@@ -153,6 +157,7 @@ export function connectShardGame<TInput = unknown>(
|
|
|
153
157
|
},
|
|
154
158
|
onReplication: (fn) => connection.onReplication(fn),
|
|
155
159
|
onInputRejected: (fn) => connection.onInputRejected(fn),
|
|
160
|
+
onTransfer: (fn) => connection.onTransfer(fn),
|
|
156
161
|
onOpen: (fn) => connection.onOpen(fn),
|
|
157
162
|
onClose: (fn) => connection.onClose(fn),
|
|
158
163
|
onError: (fn) => connection.onError(fn),
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// End-to-end: shards and the app's data (examples/shard-arena's zone, on
|
|
2
|
+
// `pylon start` processes that share one Postgres).
|
|
3
|
+
//
|
|
4
|
+
// - A zone loads a player's character when the player joins, grants an item
|
|
5
|
+
// through a mutation that runs once per key, and writes x back.
|
|
6
|
+
// - A mutation's `ctx.shards.send` reaches the zone after the commit, and
|
|
7
|
+
// not at all when it rolls back.
|
|
8
|
+
// - A machine is killed while a grant runs, and another right after a grant
|
|
9
|
+
// commits. Another machine starts the zone from its saved state, the zone
|
|
10
|
+
// sends the grant again under the same key, and the item exists once.
|
|
11
|
+
//
|
|
12
|
+
// Runs only when PYLON_SHARD_DATA_E2E is "<host:port of A>[,<host:port of
|
|
13
|
+
// F>,<host:port of G>]" and PYLON_SHARD_ADMIN_TOKEN is the machines' admin
|
|
14
|
+
// token. The kill test also needs F and G and PYLON_SHARD_DATA_KILL, "<pid
|
|
15
|
+
// of F>,<pid of G>"; machines F and G have PYLON_REPLICA_ID f and g.
|
|
16
|
+
// tools/smoke-shard-cluster.sh runs both tests on Postgres, and
|
|
17
|
+
// tools/smoke-wasm-shard.sh the first on SQLite.
|
|
18
|
+
|
|
19
|
+
import { expect, test } from "bun:test";
|
|
20
|
+
|
|
21
|
+
import { connectShard } from "./connection";
|
|
22
|
+
|
|
23
|
+
const [hostA, hostF, hostG] = (process.env.PYLON_SHARD_DATA_E2E ?? "").split(
|
|
24
|
+
",",
|
|
25
|
+
);
|
|
26
|
+
const [pidF, pidG] = (process.env.PYLON_SHARD_DATA_KILL ?? "")
|
|
27
|
+
.split(",")
|
|
28
|
+
.map(Number);
|
|
29
|
+
const adminToken = process.env.PYLON_SHARD_ADMIN_TOKEN ?? "";
|
|
30
|
+
const oneMachine = Boolean(hostA && adminToken);
|
|
31
|
+
const killable = Boolean(oneMachine && hostF && hostG && pidF && pidG);
|
|
32
|
+
|
|
33
|
+
interface Player {
|
|
34
|
+
x: number;
|
|
35
|
+
hp: number;
|
|
36
|
+
loaded: boolean;
|
|
37
|
+
character: string;
|
|
38
|
+
/** Item names by row id. */
|
|
39
|
+
items: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Zone {
|
|
43
|
+
zone: string;
|
|
44
|
+
players: Record<string, Player>;
|
|
45
|
+
/** Grant results applied to grants restored from a save. */
|
|
46
|
+
replayed: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Joined = { subscriberId: string; ticket: string; machine?: string };
|
|
50
|
+
|
|
51
|
+
async function guest(host: string): Promise<string> {
|
|
52
|
+
const res = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
|
|
53
|
+
expect(res.ok).toBe(true);
|
|
54
|
+
return ((await res.json()) as { token: string }).token;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function call<T>(
|
|
58
|
+
host: string,
|
|
59
|
+
token: string,
|
|
60
|
+
fn: string,
|
|
61
|
+
args: unknown,
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
const res = await fetch(`http://${host}/api/fn/${fn}`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
authorization: `Bearer ${token}`,
|
|
67
|
+
"content-type": "application/json",
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify(args),
|
|
70
|
+
});
|
|
71
|
+
const text = await res.text();
|
|
72
|
+
if (res.status !== 200)
|
|
73
|
+
throw new Error(`${fn} on ${host}: ${res.status} ${text}`);
|
|
74
|
+
return JSON.parse(text) as T;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Rows of `entity`, read with the admin token. */
|
|
78
|
+
async function rows(entity: string): Promise<Record<string, unknown>[]> {
|
|
79
|
+
const res = await fetch(`http://${hostA}/api/entities/${entity}?limit=1000`, {
|
|
80
|
+
headers: { authorization: `Bearer ${adminToken}` },
|
|
81
|
+
});
|
|
82
|
+
const text = await res.text();
|
|
83
|
+
if (res.status !== 200)
|
|
84
|
+
throw new Error(`list ${entity}: ${res.status} ${text}`);
|
|
85
|
+
const body = JSON.parse(text) as
|
|
86
|
+
Record<string, unknown>[] | { data: Record<string, unknown>[] };
|
|
87
|
+
return Array.isArray(body) ? body : body.data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function itemsOf(character: string): Promise<string[]> {
|
|
91
|
+
return (await rows("Item"))
|
|
92
|
+
.filter((i) => i.characterId === character)
|
|
93
|
+
.map((i) => String(i.name));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sleep(ms: number): Promise<void> {
|
|
97
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check every `every` ms until `check` gives a value. A check that calls
|
|
102
|
+
* the server uses [`HTTP_POLL_MS`]: at 50 ms, one client reaches the
|
|
103
|
+
* function rate limit, and every later check is refused.
|
|
104
|
+
*/
|
|
105
|
+
async function waitFor<T>(
|
|
106
|
+
what: string,
|
|
107
|
+
check: () => Promise<T | undefined> | T | undefined,
|
|
108
|
+
ms: number,
|
|
109
|
+
every = 50,
|
|
110
|
+
): Promise<T> {
|
|
111
|
+
const start = Date.now();
|
|
112
|
+
for (;;) {
|
|
113
|
+
const v = await check();
|
|
114
|
+
if (v !== undefined) return v;
|
|
115
|
+
if (Date.now() - start > ms)
|
|
116
|
+
throw new Error(`timed out waiting for ${what}`);
|
|
117
|
+
await sleep(every);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const HTTP_POLL_MS = 500;
|
|
122
|
+
|
|
123
|
+
/** Join zone `zone` through `host` as a new guest and wait for the character. */
|
|
124
|
+
async function joinPlayer(host: string, zone: string, machine?: string, params?: object) {
|
|
125
|
+
const token = await guest(host);
|
|
126
|
+
const joined = await call<Joined>(host, token, "joinZone", { zone, machine, params });
|
|
127
|
+
let snapshot: Zone | undefined;
|
|
128
|
+
const client = connectShard<Zone, unknown>(zone, {
|
|
129
|
+
subscriberId: joined.subscriberId,
|
|
130
|
+
baseUrl: host,
|
|
131
|
+
ticket: async (shard) =>
|
|
132
|
+
(await call<Joined>(host, token, "joinZone", { zone: shard })).ticket,
|
|
133
|
+
});
|
|
134
|
+
client.onSnapshot((s) => (snapshot = s));
|
|
135
|
+
await waitFor(
|
|
136
|
+
"the first snapshot",
|
|
137
|
+
() => (snapshot ? true : undefined),
|
|
138
|
+
10_000,
|
|
139
|
+
);
|
|
140
|
+
client.send("join");
|
|
141
|
+
const me = () => snapshot?.players[joined.subscriberId];
|
|
142
|
+
await waitFor(
|
|
143
|
+
"the character to load",
|
|
144
|
+
() => (me()?.loaded ? true : undefined),
|
|
145
|
+
10_000,
|
|
146
|
+
);
|
|
147
|
+
return {
|
|
148
|
+
token,
|
|
149
|
+
sid: joined.subscriberId,
|
|
150
|
+
client,
|
|
151
|
+
me,
|
|
152
|
+
machine: joined.machine,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The machine the directory places `zone` on (joinZone reads it, and
|
|
157
|
+
* starts nothing for a zone that has a placement). */
|
|
158
|
+
async function machineOf(token: string, zone: string): Promise<string | undefined> {
|
|
159
|
+
// While the dead machine still holds the placement, the call fails.
|
|
160
|
+
return call<Joined>(hostA, token, "joinZone", { zone }).then(
|
|
161
|
+
(j) => j.machine,
|
|
162
|
+
() => undefined,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test.skipIf(!oneMachine)(
|
|
167
|
+
"a zone loads a character, grants an item once, writes x back, and takes a GM's heal after the commit",
|
|
168
|
+
async () => {
|
|
169
|
+
const zone = `data-${Math.random().toString(36).slice(2, 8)}`;
|
|
170
|
+
const p = await joinPlayer(hostA, zone);
|
|
171
|
+
const character = p.me()!.character;
|
|
172
|
+
expect(character).toMatch(/^[0-9a-f]{40}$/);
|
|
173
|
+
|
|
174
|
+
p.client.send({ loot: { item: "sword" } });
|
|
175
|
+
await waitFor(
|
|
176
|
+
"the sword",
|
|
177
|
+
() => (Object.values(p.me()?.items ?? {}).includes("sword") ? true : undefined),
|
|
178
|
+
10_000,
|
|
179
|
+
);
|
|
180
|
+
expect(await itemsOf(character)).toEqual(["sword"]);
|
|
181
|
+
|
|
182
|
+
// x is written back within the flush interval (2 s).
|
|
183
|
+
p.client.send({ move: { dx: 5 } });
|
|
184
|
+
await waitFor(
|
|
185
|
+
"x in the Character row",
|
|
186
|
+
async () =>
|
|
187
|
+
(await rows("Character")).find((c) => c.id === character)?.x === 5
|
|
188
|
+
? true
|
|
189
|
+
: undefined,
|
|
190
|
+
10_000,
|
|
191
|
+
HTTP_POLL_MS,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// A player cannot heal; the zone refuses a heal input from a player too.
|
|
195
|
+
const refused = await call(hostA, p.token, "gmHeal", {
|
|
196
|
+
zone,
|
|
197
|
+
userId: p.sid,
|
|
198
|
+
hp: 50,
|
|
199
|
+
}).catch((e) => e);
|
|
200
|
+
expect(String(refused)).toMatch(/AUTH_REQUIRED|FORBIDDEN/);
|
|
201
|
+
p.client.send({ heal: { who: p.sid, hp: 50 } });
|
|
202
|
+
|
|
203
|
+
await call(hostA, adminToken, "gmHeal", { zone, userId: p.sid, hp: 7 });
|
|
204
|
+
await waitFor(
|
|
205
|
+
"the heal",
|
|
206
|
+
() => (p.me()?.hp === 107 ? true : undefined),
|
|
207
|
+
10_000,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Rolled back: the zone never gets it, and its log row is gone too.
|
|
211
|
+
const rolledBack = await call(hostA, adminToken, "gmHeal", {
|
|
212
|
+
zone,
|
|
213
|
+
userId: p.sid,
|
|
214
|
+
hp: 1000,
|
|
215
|
+
fail: true,
|
|
216
|
+
}).catch((e) => e);
|
|
217
|
+
expect(String(rolledBack)).toMatch(/GM_TEST_ROLLBACK/);
|
|
218
|
+
await sleep(1000);
|
|
219
|
+
expect(p.me()?.hp).toBe(107);
|
|
220
|
+
expect(
|
|
221
|
+
(await rows("GmAction")).filter((g) => g.zone === zone),
|
|
222
|
+
).toHaveLength(1);
|
|
223
|
+
p.client.close();
|
|
224
|
+
},
|
|
225
|
+
60_000,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
test.skipIf(!killable)(
|
|
229
|
+
"a machine killed during a grant, or right after its commit: the item exists once",
|
|
230
|
+
async () => {
|
|
231
|
+
const run = Math.random().toString(36).slice(2, 8);
|
|
232
|
+
|
|
233
|
+
// During: the mutation holds its transaction open for 4 s, and F dies
|
|
234
|
+
// in the middle (after the zone saved the grant, every 1 s).
|
|
235
|
+
const during = `grant-during-${run}`;
|
|
236
|
+
const pf = await joinPlayer(hostF, during, "f");
|
|
237
|
+
expect(pf.machine).toBe("f");
|
|
238
|
+
const cf = pf.me()!.character;
|
|
239
|
+
pf.client.send({ loot: { item: "relic", delay_ms: 4000 } });
|
|
240
|
+
await sleep(2000);
|
|
241
|
+
expect(await itemsOf(cf)).toEqual([]);
|
|
242
|
+
process.kill(pidF, "SIGKILL");
|
|
243
|
+
|
|
244
|
+
// After: the grant commits, and G dies after a save that still holds
|
|
245
|
+
// the grant as sent (the zone keeps grant results until `release`), so
|
|
246
|
+
// the zone that starts elsewhere must replay the stored result.
|
|
247
|
+
const after = `grant-after-${run}`;
|
|
248
|
+
const pg = await joinPlayer(hostG, after, "g", { hold_results: true });
|
|
249
|
+
expect(pg.machine).toBe("g");
|
|
250
|
+
const cg = pg.me()!.character;
|
|
251
|
+
pg.client.send({ loot: { item: "crown" } });
|
|
252
|
+
await waitFor(
|
|
253
|
+
"the crown's row",
|
|
254
|
+
async () => ((await itemsOf(cg)).length > 0 ? true : undefined),
|
|
255
|
+
10_000,
|
|
256
|
+
HTTP_POLL_MS,
|
|
257
|
+
);
|
|
258
|
+
// Saves every 1 s: at least one after the commit, with the grant.
|
|
259
|
+
await sleep(2500);
|
|
260
|
+
process.kill(pidG, "SIGKILL");
|
|
261
|
+
|
|
262
|
+
const watcherToken = await guest(hostA);
|
|
263
|
+
for (const [zone, character, item] of [
|
|
264
|
+
[during, cf, "relic"],
|
|
265
|
+
[after, cg, "crown"],
|
|
266
|
+
]) {
|
|
267
|
+
await waitFor(
|
|
268
|
+
`${zone} on another machine`,
|
|
269
|
+
async () => {
|
|
270
|
+
const m = await machineOf(watcherToken, zone);
|
|
271
|
+
return m && m !== "f" && m !== "g" ? m : undefined;
|
|
272
|
+
},
|
|
273
|
+
60_000,
|
|
274
|
+
HTTP_POLL_MS,
|
|
275
|
+
);
|
|
276
|
+
await waitFor(
|
|
277
|
+
`the ${item}'s row`,
|
|
278
|
+
async () => ((await itemsOf(character)).length > 0 ? true : undefined),
|
|
279
|
+
20_000,
|
|
280
|
+
HTTP_POLL_MS,
|
|
281
|
+
);
|
|
282
|
+
// Time for any second grant to land.
|
|
283
|
+
await sleep(3000);
|
|
284
|
+
expect(await itemsOf(character)).toEqual([item]);
|
|
285
|
+
}
|
|
286
|
+
pf.client.close();
|
|
287
|
+
pg.client.close();
|
|
288
|
+
|
|
289
|
+
// The restarted zones show each item once, after the grant's result;
|
|
290
|
+
// the second one replayed the stored result of its committed grant.
|
|
291
|
+
await expectShown(during, pf.sid, "relic", false);
|
|
292
|
+
await expectShown(after, pg.sid, "crown", true);
|
|
293
|
+
},
|
|
294
|
+
180_000,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Watch `zone` as a new guest until player `sid` shows `item`, once. With
|
|
299
|
+
* `replay`, release the zone's held grant results until one restored from
|
|
300
|
+
* its save was applied.
|
|
301
|
+
*/
|
|
302
|
+
async function expectShown(zone: string, sid: string, item: string, replay: boolean) {
|
|
303
|
+
const token = await guest(hostA);
|
|
304
|
+
const t = await call<Joined>(hostA, token, "joinZone", { zone });
|
|
305
|
+
let snapshot: Zone | undefined;
|
|
306
|
+
const watcher = connectShard<Zone, unknown>(zone, {
|
|
307
|
+
subscriberId: t.subscriberId,
|
|
308
|
+
baseUrl: hostA,
|
|
309
|
+
ticket: async (shard) =>
|
|
310
|
+
(await call<Joined>(hostA, token, "joinZone", { zone: shard })).ticket,
|
|
311
|
+
});
|
|
312
|
+
watcher.onSnapshot((s) => (snapshot = s));
|
|
313
|
+
await waitFor(
|
|
314
|
+
`the ${item} in ${zone}`,
|
|
315
|
+
() => (Object.values(snapshot?.players[sid]?.items ?? {}).includes(item) ? true : undefined),
|
|
316
|
+
20_000,
|
|
317
|
+
);
|
|
318
|
+
if (replay) {
|
|
319
|
+
await waitFor(
|
|
320
|
+
`the replayed grant in ${zone}`,
|
|
321
|
+
() => {
|
|
322
|
+
if (snapshot?.replayed === 1) return true;
|
|
323
|
+
watcher.send("release");
|
|
324
|
+
return undefined;
|
|
325
|
+
},
|
|
326
|
+
20_000,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
expect(Object.values(snapshot!.players[sid].items)).toEqual([item]);
|
|
330
|
+
watcher.close();
|
|
331
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// End-to-end: a deploy does not disconnect players (issue #34). A zone runs
|
|
2
|
+
// on machine D; a player there has hit points, a buff, and a position. D
|
|
3
|
+
// gets SIGTERM, as in a rolling deploy: it hands the zone to another
|
|
4
|
+
// machine with its state and tells the client, which reconnects at once.
|
|
5
|
+
// The player sees no error and no transfer, and its state is the same.
|
|
6
|
+
//
|
|
7
|
+
// Runs only when PYLON_SHARD_DRAIN_E2E is "<host:port of A>,<host:port of
|
|
8
|
+
// D>" and PYLON_SHARD_DRAIN_PID is D's pid (tools/smoke-shard-cluster.sh
|
|
9
|
+
// sets both). Machine D has PYLON_REPLICA_ID d and saves shards only when
|
|
10
|
+
// it stops (PYLON_SHARD_SAVE_SECS=3600).
|
|
11
|
+
|
|
12
|
+
import { expect, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
import { connectShard } from "./connection";
|
|
15
|
+
|
|
16
|
+
const [hostA, hostD] = (process.env.PYLON_SHARD_DRAIN_E2E ?? "").split(",");
|
|
17
|
+
const pidD = Number(process.env.PYLON_SHARD_DRAIN_PID ?? "");
|
|
18
|
+
|
|
19
|
+
interface Player {
|
|
20
|
+
x: number;
|
|
21
|
+
hp: number;
|
|
22
|
+
loaded: boolean;
|
|
23
|
+
buffs: Array<{ name: string; remaining_ms: number }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Zone {
|
|
27
|
+
zone: string;
|
|
28
|
+
players: Record<string, Player>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Joined = { subscriberId: string; ticket: string; machine?: string };
|
|
32
|
+
|
|
33
|
+
async function guest(host: string): Promise<string> {
|
|
34
|
+
const res = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
|
|
35
|
+
expect(res.ok).toBe(true);
|
|
36
|
+
return ((await res.json()) as { token: string }).token;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function call<T>(host: string, token: string, fn: string, args: unknown): Promise<T> {
|
|
40
|
+
const res = await fetch(`http://${host}/api/fn/${fn}`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
43
|
+
body: JSON.stringify(args),
|
|
44
|
+
});
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
if (res.status !== 200) throw new Error(`${fn} on ${host}: ${res.status} ${text}`);
|
|
47
|
+
return JSON.parse(text) as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function waitFor<T>(what: string, check: () => T | undefined, ms: number): Promise<T> {
|
|
51
|
+
const start = Date.now();
|
|
52
|
+
for (;;) {
|
|
53
|
+
const v = check();
|
|
54
|
+
if (v !== undefined) return v;
|
|
55
|
+
if (Date.now() - start > ms) throw new Error(`timed out waiting for ${what}`);
|
|
56
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test.skipIf(!hostA || !hostD || !pidD)(
|
|
61
|
+
"a machine that shuts down hands its zone to another with the player's state, and the client follows with no error",
|
|
62
|
+
async () => {
|
|
63
|
+
const zone = `drain-${Math.random().toString(36).slice(2, 8)}`;
|
|
64
|
+
const token = await guest(hostA);
|
|
65
|
+
const joined = await call<Joined>(hostA, token, "joinZone", { zone, machine: "d" });
|
|
66
|
+
expect(joined.machine).toBe("d");
|
|
67
|
+
const me = joined.subscriberId;
|
|
68
|
+
|
|
69
|
+
let latest: Zone | undefined;
|
|
70
|
+
let opens = 0;
|
|
71
|
+
const errors: Error[] = [];
|
|
72
|
+
const transfers: string[] = [];
|
|
73
|
+
const client = connectShard<Zone, unknown>(zone, {
|
|
74
|
+
subscriberId: me,
|
|
75
|
+
baseUrl: hostA,
|
|
76
|
+
ticket: async (shard) => (await call<Joined>(hostA, token, "joinZone", { zone: shard })).ticket,
|
|
77
|
+
});
|
|
78
|
+
client.onSnapshot((s) => (latest = s));
|
|
79
|
+
client.onOpen(() => (opens += 1));
|
|
80
|
+
// A snapshot from before a reconnect says nothing about the new one.
|
|
81
|
+
client.onClose(() => (latest = undefined));
|
|
82
|
+
client.onError((e) => errors.push(e));
|
|
83
|
+
client.onTransfer((to) => transfers.push(to));
|
|
84
|
+
const mine = () => latest?.players[me];
|
|
85
|
+
|
|
86
|
+
await waitFor("the zone's first snapshot", () => latest, 10_000);
|
|
87
|
+
client.send("join");
|
|
88
|
+
await waitFor("the character to load", () => (mine()?.loaded ? true : undefined), 10_000);
|
|
89
|
+
client.send({ buff: { name: "haste", ms: 600_000 } });
|
|
90
|
+
client.send({ hit: { damage: 30 } });
|
|
91
|
+
client.send({ move: { dx: 3 } });
|
|
92
|
+
await waitFor(
|
|
93
|
+
"the player's state",
|
|
94
|
+
() => (mine()?.hp === 70 && mine()?.x === 3 && mine()?.buffs.length ? true : undefined),
|
|
95
|
+
10_000,
|
|
96
|
+
);
|
|
97
|
+
// D saves only at shutdown (PYLON_SHARD_SAVE_SECS is an hour there):
|
|
98
|
+
// the final save is what carries this state.
|
|
99
|
+
|
|
100
|
+
const openedBefore = opens;
|
|
101
|
+
const killedAt = Date.now();
|
|
102
|
+
process.kill(pidD, "SIGTERM");
|
|
103
|
+
|
|
104
|
+
// The client reconnects, to the zone on another machine.
|
|
105
|
+
await waitFor("the reconnect", () => (opens > openedBefore ? true : undefined), 15_000);
|
|
106
|
+
const after = await waitFor(
|
|
107
|
+
"the player in the moved zone",
|
|
108
|
+
() => (opens > openedBefore && mine()?.loaded ? mine() : undefined),
|
|
109
|
+
15_000,
|
|
110
|
+
);
|
|
111
|
+
expect(Date.now() - killedAt).toBeLessThan(10_000);
|
|
112
|
+
expect(after.hp).toBe(70);
|
|
113
|
+
expect(after.x).toBe(3);
|
|
114
|
+
expect(after.buffs.map((b) => b.name)).toEqual(["haste"]);
|
|
115
|
+
expect(errors).toEqual([]);
|
|
116
|
+
expect(transfers).toEqual([]);
|
|
117
|
+
expect(client.shardId).toBe(zone);
|
|
118
|
+
const now = await call<Joined>(hostA, token, "joinZone", { zone });
|
|
119
|
+
expect(now.machine).not.toBe("d");
|
|
120
|
+
|
|
121
|
+
// Inputs work on the new machine.
|
|
122
|
+
client.send({ hit: { damage: 1 } });
|
|
123
|
+
await waitFor("an input on the new machine", () => (mine()?.hp === 69 ? true : undefined), 10_000);
|
|
124
|
+
client.close();
|
|
125
|
+
},
|
|
126
|
+
60_000,
|
|
127
|
+
);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// End-to-end: a message from a shard on one machine reaches a shard on
|
|
2
|
+
// another (examples/shard-arena, two `pylon start` processes on one
|
|
3
|
+
// Postgres, no cluster bus: the machines call each other).
|
|
4
|
+
//
|
|
5
|
+
// Runs only when PYLON_SHARD_MESSAGES_E2E is "<host:port of A>,<host:port of
|
|
6
|
+
// B>" and PYLON_SHARD_ADMIN_TOKEN is the machines' admin token
|
|
7
|
+
// (tools/smoke-shard-cluster.sh sets both).
|
|
8
|
+
|
|
9
|
+
import { expect, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
import { connectShard } from "./connection";
|
|
12
|
+
|
|
13
|
+
const [hostA, hostB] = (process.env.PYLON_SHARD_MESSAGES_E2E ?? "").split(",");
|
|
14
|
+
const adminToken = process.env.PYLON_SHARD_ADMIN_TOKEN ?? "";
|
|
15
|
+
|
|
16
|
+
interface Zone {
|
|
17
|
+
zone: string;
|
|
18
|
+
heard: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function guest(host: string): Promise<string> {
|
|
22
|
+
const res = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
|
|
23
|
+
expect(res.ok).toBe(true);
|
|
24
|
+
return ((await res.json()) as { token: string }).token;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function call<T>(host: string, token: string, fn: string, args: unknown): Promise<T> {
|
|
28
|
+
const res = await fetch(`http://${host}/api/fn/${fn}`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
31
|
+
body: JSON.stringify(args),
|
|
32
|
+
});
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
if (res.status !== 200) throw new Error(`${fn} on ${host}: ${res.status} ${text}`);
|
|
35
|
+
return JSON.parse(text) as T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function waitFor<T>(what: string, check: () => T | undefined, ms: number): Promise<T> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const start = Date.now();
|
|
41
|
+
const timer = setInterval(() => {
|
|
42
|
+
const v = check();
|
|
43
|
+
if (v !== undefined) {
|
|
44
|
+
clearInterval(timer);
|
|
45
|
+
resolve(v);
|
|
46
|
+
} else if (Date.now() - start > ms) {
|
|
47
|
+
clearInterval(timer);
|
|
48
|
+
reject(new Error(`timed out waiting for ${what}`));
|
|
49
|
+
}
|
|
50
|
+
}, 25);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test.skipIf(!hostA || !hostB || !adminToken)(
|
|
55
|
+
"a message from a shard on one machine reaches a shard on another",
|
|
56
|
+
async () => {
|
|
57
|
+
const run = Math.random().toString(36).slice(2, 8);
|
|
58
|
+
const [west, east] = [`msg-west-${run}`, `msg-east-${run}`];
|
|
59
|
+
type Joined = { subscriberId: string; ticket: string; machine?: string };
|
|
60
|
+
const shouter = await guest(hostA);
|
|
61
|
+
const watcher = await guest(hostB);
|
|
62
|
+
const w = await call<Joined>(hostA, shouter, "joinZone", { zone: west, machine: "a" });
|
|
63
|
+
expect(w.machine).toBe("a");
|
|
64
|
+
const e = await call<Joined>(hostB, watcher, "joinZone", {
|
|
65
|
+
zone: east,
|
|
66
|
+
machine: "b",
|
|
67
|
+
params: { group: `realm-${run}` },
|
|
68
|
+
});
|
|
69
|
+
expect(e.machine).toBe("b");
|
|
70
|
+
|
|
71
|
+
let heard: string[] = [];
|
|
72
|
+
const eastClient = connectShard<Zone, unknown>(east, {
|
|
73
|
+
subscriberId: e.subscriberId,
|
|
74
|
+
baseUrl: hostB,
|
|
75
|
+
ticket: async (shard) => (await call<Joined>(hostB, watcher, "joinZone", { zone: shard })).ticket,
|
|
76
|
+
});
|
|
77
|
+
eastClient.onSnapshot((s) => (heard = s.heard));
|
|
78
|
+
await waitFor("east's first snapshot", () => (eastClient.tick >= 0 ? true : undefined), 10_000);
|
|
79
|
+
|
|
80
|
+
let westTick = -1;
|
|
81
|
+
const westClient = connectShard<Zone, unknown>(west, {
|
|
82
|
+
subscriberId: w.subscriberId,
|
|
83
|
+
baseUrl: hostA,
|
|
84
|
+
ticket: async (shard) => (await call<Joined>(hostA, shouter, "joinZone", { zone: shard })).ticket,
|
|
85
|
+
});
|
|
86
|
+
westClient.onSnapshot((_, tick) => (westTick = tick));
|
|
87
|
+
await waitFor("west's first snapshot", () => (westTick >= 0 ? true : undefined), 10_000);
|
|
88
|
+
westClient.send("join");
|
|
89
|
+
// To all zones, and to east's group.
|
|
90
|
+
westClient.send({ shout: { text: "the relic is taken" } });
|
|
91
|
+
westClient.send({ shout: { text: "to the realm", to: `group:realm-${run}` } });
|
|
92
|
+
const from = `${west}> ${w.subscriberId}`;
|
|
93
|
+
await waitFor(
|
|
94
|
+
"east hears both",
|
|
95
|
+
() =>
|
|
96
|
+
heard.includes(`${from}: the relic is taken`) && heard.includes(`${from}: to the realm`)
|
|
97
|
+
? true
|
|
98
|
+
: undefined,
|
|
99
|
+
10_000,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// A server function on A announces to all: east, on B, hears it. A
|
|
103
|
+
// player (here a guest) may not announce.
|
|
104
|
+
const refused = await call(hostA, shouter, "announce", { text: "fake" }).catch((e) => e);
|
|
105
|
+
expect(String(refused)).toMatch(/AUTH_REQUIRED|FORBIDDEN/);
|
|
106
|
+
await call(hostA, adminToken, "announce", { text: "keep under attack" });
|
|
107
|
+
await waitFor(
|
|
108
|
+
"east hears the announcement",
|
|
109
|
+
() => (heard.includes(`> "keep under attack"`) ? true : undefined),
|
|
110
|
+
10_000,
|
|
111
|
+
);
|
|
112
|
+
westClient.close();
|
|
113
|
+
eastClient.close();
|
|
114
|
+
},
|
|
115
|
+
60_000,
|
|
116
|
+
);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// End-to-end: a player moves between shards on two machines
|
|
2
|
+
// (examples/shard-arena, two `pylon start` processes on one Postgres), with
|
|
3
|
+
// its hit points, buffs, and cooldowns. A move the target refuses leaves the
|
|
4
|
+
// player in the source.
|
|
5
|
+
//
|
|
6
|
+
// Runs only when PYLON_SHARD_TRANSFER_E2E is "<host:port of A>,<host:port of
|
|
7
|
+
// B>" (tools/smoke-shard-cluster.sh sets it).
|
|
8
|
+
|
|
9
|
+
import { expect, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
import { connectShard } from "./connection";
|
|
12
|
+
import type { ShardInputRejection } from "./wire";
|
|
13
|
+
|
|
14
|
+
const [hostA, hostB] = (process.env.PYLON_SHARD_TRANSFER_E2E ?? "").split(",");
|
|
15
|
+
|
|
16
|
+
interface Player {
|
|
17
|
+
x: number;
|
|
18
|
+
hp: number;
|
|
19
|
+
buffs: Array<{ name: string; remaining_ms: number }>;
|
|
20
|
+
cooldowns: Record<string, number>;
|
|
21
|
+
loaded: boolean;
|
|
22
|
+
}
|
|
23
|
+
interface Zone {
|
|
24
|
+
zone: string;
|
|
25
|
+
players: Record<string, Player>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function guest(host: string): Promise<string> {
|
|
29
|
+
const res = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
|
|
30
|
+
expect(res.ok).toBe(true);
|
|
31
|
+
return ((await res.json()) as { token: string }).token;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function call<T>(host: string, token: string, fn: string, args: unknown): Promise<T> {
|
|
35
|
+
const res = await fetch(`http://${host}/api/fn/${fn}`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify(args),
|
|
39
|
+
});
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
if (res.status !== 200) {
|
|
42
|
+
const err = new Error(`${fn} on ${host}: ${res.status} ${text}`) as Error & { code?: string };
|
|
43
|
+
try {
|
|
44
|
+
err.code = (JSON.parse(text) as { error?: { code?: string } }).error?.code;
|
|
45
|
+
} catch {}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
return JSON.parse(text) as T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function waitFor<T>(what: string, check: () => T | undefined, ms: number): Promise<T> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const start = Date.now();
|
|
54
|
+
const timer = setInterval(() => {
|
|
55
|
+
const v = check();
|
|
56
|
+
if (v !== undefined) {
|
|
57
|
+
clearInterval(timer);
|
|
58
|
+
resolve(v);
|
|
59
|
+
} else if (Date.now() - start > ms) {
|
|
60
|
+
clearInterval(timer);
|
|
61
|
+
reject(new Error(`timed out waiting for ${what}`));
|
|
62
|
+
}
|
|
63
|
+
}, 25);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test.skipIf(!hostA || !hostB)(
|
|
68
|
+
"a player moves to a shard on another machine with its state, and a refused move leaves it",
|
|
69
|
+
async () => {
|
|
70
|
+
const run = Math.random().toString(36).slice(2, 8);
|
|
71
|
+
const [west, east, shut] = [`west-${run}`, `east-${run}`, `shut-${run}`];
|
|
72
|
+
const token = await guest(hostA);
|
|
73
|
+
type Joined = { subscriberId: string; ticket: string; machine?: string };
|
|
74
|
+
const first = await call<Joined>(hostA, token, "joinZone", { zone: west, machine: "a" });
|
|
75
|
+
expect(first.machine).toBe("a");
|
|
76
|
+
expect((await call<Joined>(hostA, token, "joinZone", { zone: east, machine: "b" })).machine).toBe("b");
|
|
77
|
+
const closed = { zone: shut, machine: "b", params: { closed: true } };
|
|
78
|
+
expect((await call<Joined>(hostA, token, "joinZone", closed)).machine).toBe("b");
|
|
79
|
+
const me = first.subscriberId;
|
|
80
|
+
|
|
81
|
+
let latest: Zone | undefined;
|
|
82
|
+
const moves: string[] = [];
|
|
83
|
+
const rejections: ShardInputRejection[] = [];
|
|
84
|
+
const client = connectShard<Zone, unknown>(west, {
|
|
85
|
+
subscriberId: me,
|
|
86
|
+
baseUrl: hostA,
|
|
87
|
+
reconnectBackoffMs: 250,
|
|
88
|
+
ticket: async (shard) => (await call<Joined>(hostA, token, "joinZone", { zone: shard })).ticket,
|
|
89
|
+
});
|
|
90
|
+
client.onSnapshot((snap) => (latest = snap));
|
|
91
|
+
client.onTransfer((to) => moves.push(to));
|
|
92
|
+
client.onInputRejected((r) => rejections.push(r));
|
|
93
|
+
|
|
94
|
+
await waitFor("a snapshot of the west zone", () => latest, 10_000);
|
|
95
|
+
client.send("join");
|
|
96
|
+
client.send({ buff: { name: "haste", ms: 120_000 } });
|
|
97
|
+
client.send({ cast: { ability: "fireball", cooldown_ms: 120_000 } });
|
|
98
|
+
client.send({ hit: { damage: 30 } });
|
|
99
|
+
const mine = () => latest?.players[me];
|
|
100
|
+
// x comes from the character row: the zone refuses a move before it loads.
|
|
101
|
+
await waitFor("the character to load", () => (mine()?.loaded ? true : undefined), 10_000);
|
|
102
|
+
client.send({ move: { dx: 2 } });
|
|
103
|
+
const before = await waitFor(
|
|
104
|
+
"the player's state in west",
|
|
105
|
+
() => (mine()?.hp === 70 && mine()?.x === 2 && mine()?.buffs.length ? mine() : undefined),
|
|
106
|
+
10_000,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// The closed zone refuses: the player stays in west, still connected.
|
|
110
|
+
const refused = await call(hostA, token, "moveZone", { from: west, to: shut }).catch((e) => e);
|
|
111
|
+
expect((refused as { code?: string }).code).toBe("SHARD_TRANSFER_REFUSED");
|
|
112
|
+
expect(client.shardId).toBe(west);
|
|
113
|
+
client.send({ hit: { damage: 1 } });
|
|
114
|
+
await waitFor("west still has the player", () => (mine()?.hp === 69 ? true : undefined), 10_000);
|
|
115
|
+
|
|
116
|
+
// Moved to east, on machine B: the client follows the transfer frame.
|
|
117
|
+
const done = await call<{ shard: string }>(hostA, token, "moveZone", { from: west, to: east });
|
|
118
|
+
expect(done.shard).toBe(east);
|
|
119
|
+
await waitFor("the transfer frame", () => (moves.length ? true : undefined), 10_000);
|
|
120
|
+
expect(moves).toEqual([east]);
|
|
121
|
+
expect(client.shardId).toBe(east);
|
|
122
|
+
const after = await waitFor(
|
|
123
|
+
"the player in east",
|
|
124
|
+
() => (latest?.zone === east ? mine() : undefined),
|
|
125
|
+
10_000,
|
|
126
|
+
);
|
|
127
|
+
expect(after.hp).toBe(69);
|
|
128
|
+
expect(after.x).toBe(2);
|
|
129
|
+
expect(after.buffs.map((b) => b.name)).toEqual(["haste"]);
|
|
130
|
+
const haste = (p: Player) => p.buffs[0].remaining_ms;
|
|
131
|
+
expect(haste(after)).toBeLessThanOrEqual(haste(before));
|
|
132
|
+
expect(haste(after)).toBeGreaterThan(haste(before) - 10_000);
|
|
133
|
+
expect(after.cooldowns.fireball).toBeLessThanOrEqual(before.cooldowns.fireball);
|
|
134
|
+
expect(after.cooldowns.fireball).toBeGreaterThan(before.cooldowns.fireball - 10_000);
|
|
135
|
+
|
|
136
|
+
// The cooldown came along: east refuses the cast.
|
|
137
|
+
client.send({ cast: { ability: "fireball", cooldown_ms: 1 } });
|
|
138
|
+
await waitFor(
|
|
139
|
+
"the cooldown refusal",
|
|
140
|
+
() => (rejections.some((r) => r.message.includes("cooling down")) ? true : undefined),
|
|
141
|
+
10_000,
|
|
142
|
+
);
|
|
143
|
+
// West no longer has the player: an observer there sees no entity for
|
|
144
|
+
// it, in the running zone.
|
|
145
|
+
const observerToken = await guest(hostA);
|
|
146
|
+
const observer = await call<Joined>(hostA, observerToken, "joinZone", { zone: west });
|
|
147
|
+
expect(observer.machine).toBe("a");
|
|
148
|
+
let westView: Zone | undefined;
|
|
149
|
+
const watcher = connectShard<Zone, unknown>(west, {
|
|
150
|
+
subscriberId: observer.subscriberId,
|
|
151
|
+
baseUrl: hostA,
|
|
152
|
+
ticket: async (shard) =>
|
|
153
|
+
(await call<Joined>(hostA, observerToken, "joinZone", { zone: shard })).ticket,
|
|
154
|
+
});
|
|
155
|
+
watcher.onSnapshot((snap) => (westView = snap));
|
|
156
|
+
await waitFor("a snapshot of west", () => westView, 10_000);
|
|
157
|
+
expect(westView!.zone).toBe(west);
|
|
158
|
+
expect(westView!.players[me]).toBeUndefined();
|
|
159
|
+
watcher.close();
|
|
160
|
+
client.close();
|
|
161
|
+
},
|
|
162
|
+
60_000,
|
|
163
|
+
);
|
package/src/wire.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* A client asks for it with `?v=2` on the shard WebSocket URL. Each server
|
|
5
5
|
* message is a binary frame with an 18-byte header:
|
|
6
6
|
*
|
|
7
|
-
* 0 1 frame kind: 1 snapshot, 2 input rejected, 3 entity replication
|
|
7
|
+
* 0 1 frame kind: 1 snapshot, 2 input rejected, 3 entity replication,
|
|
8
|
+
* 4 transfer (JSON `{ shard, ticket }`: the last frame; reconnect there)
|
|
8
9
|
* 1 1 codec: 0 JSON, 1 MessagePack, 2 bincode, 3 custom, 4 replication
|
|
9
10
|
* 2 8 tick (u64 big-endian)
|
|
10
11
|
* 10 8 ack: highest client_seq the shard processed for this subscriber (0 = none)
|
|
@@ -24,8 +25,19 @@ export const ShardFrameKind = {
|
|
|
24
25
|
InputRejected: 2,
|
|
25
26
|
/** An entity replication frame: apply it to an `EntityTable`. */
|
|
26
27
|
Replication: 3,
|
|
28
|
+
/**
|
|
29
|
+
* The subscriber moved to another shard (`ShardTransferNotice`, JSON). The
|
|
30
|
+
* last frame on the connection.
|
|
31
|
+
*/
|
|
32
|
+
Transfer: 4,
|
|
27
33
|
} as const;
|
|
28
34
|
|
|
35
|
+
/** Where the subscriber went: connect to `shard` with `ticket`. */
|
|
36
|
+
export interface ShardTransferNotice {
|
|
37
|
+
shard: string;
|
|
38
|
+
ticket: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
export const ShardCodec = {
|
|
30
42
|
Json: 0,
|
|
31
43
|
MessagePack: 1,
|
|
@@ -46,7 +58,10 @@ export interface ShardFrame {
|
|
|
46
58
|
/** Why an input did not take effect. */
|
|
47
59
|
export interface ShardInputRejection {
|
|
48
60
|
clientSeq: number | null;
|
|
49
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* `unauthorized`, `rate_limited`, `queue_full`, `invalid`, `stopped`,
|
|
63
|
+
* `transferring`, or `apply_failed`.
|
|
64
|
+
*/
|
|
50
65
|
code: string;
|
|
51
66
|
message: string;
|
|
52
67
|
}
|