@pylonsync/realtime 0.14.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.
@@ -0,0 +1,234 @@
1
+ // End-to-end: examples/world3d's island shard on a real `pylon start`, with
2
+ // two players on connectShardGame: joins at the shard's spawn points, moves
3
+ // drawn by the other player, a move the shard clamps and prediction
4
+ // follows, hits and the damage limit, a refused respawn, a leave, and a
5
+ // connection without a ticket refused.
6
+ //
7
+ // Runs only when PYLON_WORLD3D_E2E holds the server's host:port or its
8
+ // origin (tools/smoke-wasm-shard.sh sets it up).
9
+
10
+ import { expect, test } from "bun:test";
11
+
12
+ import { connectShardGame, type ShardGame } from "./game";
13
+ import type { ShardInputRejection } from "./wire";
14
+
15
+ const target = process.env.PYLON_WORLD3D_E2E ?? "";
16
+ const origin = target.includes("://") ? target.replace(/\/$/, "") : `http://${target}`;
17
+ const host = target ? new URL(origin).host : "";
18
+
19
+ type Input =
20
+ | "join"
21
+ | { move: { dx: number; dy: number; dz: number; heading: number; pitch: number } }
22
+ | { hit: { target: number; damage: number } }
23
+ | "spawn"
24
+ | "leave";
25
+
26
+ const AVATAR = 1;
27
+ const HEALTH = 3;
28
+
29
+ async function call<T>(token: string, fn: string, args: unknown): Promise<T> {
30
+ const res = await fetch(`${origin}/api/fn/${fn}`, {
31
+ method: "POST",
32
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
33
+ body: JSON.stringify(args),
34
+ });
35
+ const body = await res.text();
36
+ if (res.status !== 200) throw new Error(`${fn}: ${res.status} ${body}`);
37
+ return JSON.parse(body) as T;
38
+ }
39
+
40
+ interface Player {
41
+ avatarId: string;
42
+ token: string;
43
+ userId: string;
44
+ game: ShardGame<Input>;
45
+ rejections: ShardInputRejection[];
46
+ errors: Error[];
47
+ x: number;
48
+ }
49
+
50
+ async function player(): Promise<Player> {
51
+ const guest = await fetch(`${origin}/api/auth/guest`, { method: "POST" });
52
+ expect(guest.ok).toBe(true);
53
+ const { token, user_id } = (await guest.json()) as { token: string; user_id: string };
54
+ const { id } = await call<{ id: string }>(token, "spawnAvatar", { userId: user_id });
55
+ const game = connectShardGame<Input>("island-main", {
56
+ subscriberId: id,
57
+ baseUrl: host,
58
+ tickRate: 20,
59
+ ticket: async () => (await call<{ ticket: string }>(token, "joinIsland", {})).ticket,
60
+ });
61
+ const p: Player = { avatarId: id, token, userId: user_id, game, rejections: [], errors: [], x: 0 };
62
+ game.onInputRejected((r) => p.rejections.push(r));
63
+ game.onError((e) => p.errors.push(e));
64
+ return p;
65
+ }
66
+
67
+ /** The entity whose AVATAR component is `avatarId`, in `p`'s newest table. */
68
+ function entityOf(p: Player, avatarId: string): number | undefined {
69
+ for (const e of p.game.latest.entities.values()) {
70
+ const bytes = e.components.get(AVATAR);
71
+ if (bytes && new TextDecoder().decode(bytes) === avatarId) return e.id;
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function waitFor<T>(what: string, check: () => T | undefined, ms = 8000): Promise<T> {
77
+ return new Promise((resolve, reject) => {
78
+ const start = Date.now();
79
+ const timer = setInterval(() => {
80
+ const v = check();
81
+ if (v !== undefined) {
82
+ clearInterval(timer);
83
+ resolve(v);
84
+ } else if (Date.now() - start > ms) {
85
+ clearInterval(timer);
86
+ reject(new Error(`timed out waiting for ${what}`));
87
+ }
88
+ }, 20);
89
+ });
90
+ }
91
+
92
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
93
+ const move = (dx: number): Input => ({ move: { dx, dy: 0, dz: 0, heading: 0.5, pitch: 0 } });
94
+
95
+ test.skipIf(!host)("two players on the island shard with connectShardGame", async () => {
96
+ const [a, b] = await Promise.all([player(), player()]);
97
+ await waitFor("both connected", () => (a.game.connected && b.game.connected ? true : undefined));
98
+ expect(a.game.send("join")).toBeGreaterThan(0);
99
+ b.game.send("join");
100
+
101
+ const aEntity = await waitFor("B sees A", () => entityOf(b, a.avatarId));
102
+ const bEntity = await waitFor("A sees B", () => entityOf(a, b.avatarId));
103
+ expect(entityOf(a, a.avatarId)).toBe(aEntity);
104
+ // The shard picked the spawn point (shards/island/spawns.json).
105
+ const start = a.game.latest.get(aEntity)!.x;
106
+ expect(Number.isFinite(start)).toBe(true);
107
+
108
+ // Prediction: on every frame, the predicted x is the shard's x plus the
109
+ // moves it has not processed.
110
+ const me = a.game.predict<number>((x, input) =>
111
+ typeof input === "object" && "move" in input ? x + input.move.dx : x,
112
+ );
113
+ const sent = new Map<number, number>();
114
+ let predicted = 0;
115
+ let checked = 0;
116
+ a.game.onReplication((table, _s, _t, ack) => {
117
+ const mine = table.get(aEntity);
118
+ if (!mine) return;
119
+ predicted = me.reconcile(mine.x, ack);
120
+ let pending = 0;
121
+ for (const [seq, dx] of sent) if (seq > ack) pending += dx;
122
+ expect(predicted).toBeCloseTo(mine.x + pending, 6);
123
+ checked++;
124
+ });
125
+ const send = (dx: number) => {
126
+ const seq = a.game.send(move(dx));
127
+ sent.set(seq, dx);
128
+ return seq;
129
+ };
130
+
131
+ // A walks 10 m at 5 m/s; B draws A in between, then at start + 10.
132
+ const drawn: number[] = [];
133
+ for (let i = 0; i < 40; i++) {
134
+ send(0.25);
135
+ b.game.frame();
136
+ const e = b.game.entities.get(aEntity);
137
+ if (e) drawn.push(e.x);
138
+ await sleep(50);
139
+ }
140
+ const arrived = await waitFor("B draws A 10 m on", () => {
141
+ b.game.frame();
142
+ const x = b.game.entities.get(aEntity)?.x;
143
+ return x !== undefined && Math.abs(x - (start + 10)) < 0.02 ? x : undefined;
144
+ });
145
+ expect(arrived).toBeCloseTo(start + 10, 1);
146
+ // Drawn between frames: many distinct positions, never backwards.
147
+ expect(new Set(drawn.map((x) => x.toFixed(3))).size).toBeGreaterThan(20);
148
+ for (let i = 1; i < drawn.length; i++) expect(drawn[i]).toBeGreaterThanOrEqual(drawn[i - 1] - 1e-6);
149
+ expect(b.game.clock.tickMs).toBe(50);
150
+ expect(a.game.rttMs).not.toBeNull();
151
+
152
+ // A teleport attempt: the shard allows its saved budget (18 m), and the
153
+ // prediction follows the shard.
154
+ const clampedSeq = send(500);
155
+ send(0.5);
156
+ await waitFor("the clamped move acknowledged", () => (a.game.ack >= clampedSeq + 1 ? true : undefined));
157
+ const x = a.game.latest.get(aEntity)!.x;
158
+ expect(x - start).toBeGreaterThan(10);
159
+ expect(x - start).toBeLessThan(10 + 18.6);
160
+ expect(predicted).toBeCloseTo(x, 6);
161
+ expect(checked).toBeGreaterThan(20);
162
+
163
+ // A shoots B, C, and D twice each at once. Damage is capped at 60 a hit
164
+ // and 300 at once: B and C go down, D takes one hit, and the last is
165
+ // refused.
166
+ const [c, d] = await Promise.all([player(), player()]);
167
+ await waitFor("C and D connected", () => (c.game.connected && d.game.connected ? true : undefined));
168
+ c.game.send("join");
169
+ d.game.send("join");
170
+ const cEntity = await waitFor("A sees C", () => entityOf(a, c.avatarId));
171
+ const dEntity = await waitFor("A sees D", () => entityOf(a, d.avatarId));
172
+ const hits = [bEntity, bEntity, cEntity, cEntity, dEntity, dEntity].map((target) =>
173
+ a.game.send({ hit: { target, damage: 255 } }),
174
+ );
175
+ await waitFor("B dead in B's own table", () =>
176
+ b.game.latest.get(bEntity)?.components.get(HEALTH)?.[0] === 0 ? true : undefined,
177
+ );
178
+ await waitFor("the last hit refused", () =>
179
+ a.rejections.find((r) => r.clientSeq === hits[5] && r.message.includes("faster")),
180
+ );
181
+ await waitFor("D hit once", () =>
182
+ d.game.latest.get(dEntity)?.components.get(HEALTH)?.[0] === 40 ? true : undefined,
183
+ );
184
+ // Too soon to respawn: refused, with the shard's reason.
185
+ const spawnSeq = b.game.send("spawn");
186
+ const refused = await waitFor("the respawn refused", () =>
187
+ b.rejections.find((r) => r.clientSeq === spawnSeq),
188
+ );
189
+ expect(refused.message).toContain("too soon");
190
+
191
+ // A leaves: the shard keeps A for 5 s (leaving is no escape from a
192
+ // fight), then B drops it.
193
+ a.game.send("leave");
194
+ await waitFor(
195
+ "A gone from B's view",
196
+ () => {
197
+ b.game.frame();
198
+ return b.game.entities.has(aEntity) ? undefined : true;
199
+ },
200
+ 12_000,
201
+ );
202
+
203
+ expect(a.errors).toEqual([]);
204
+ expect(b.errors).toEqual([]);
205
+ a.game.close();
206
+ b.game.close();
207
+ c.game.close();
208
+ d.game.close();
209
+ // A leave lingers 5 s before the entity goes.
210
+ }, 30_000);
211
+
212
+ test.skipIf(!host)("a connection without a joinIsland ticket is refused", async () => {
213
+ // The shard runs (a player joined through joinIsland), so the refusal is
214
+ // for the missing ticket.
215
+ const other = await player();
216
+ await waitFor("the shard running", () => (other.game.connected ? true : undefined));
217
+ // A guest session names itself as the subscriber, with no ticket.
218
+ const guest = await fetch(`${origin}/api/auth/guest`, { method: "POST" });
219
+ const { token, user_id } = (await guest.json()) as { token: string; user_id: string };
220
+ const game = connectShardGame<Input>("island-main", {
221
+ subscriberId: user_id,
222
+ baseUrl: host,
223
+ token,
224
+ autoReconnect: false,
225
+ });
226
+ let closed = false;
227
+ game.onClose(() => (closed = true));
228
+ game.onError(() => {});
229
+ await waitFor("the refused connection to close", () => (closed ? true : undefined));
230
+ expect(game.latest.size).toBe(0);
231
+ expect(game.connected).toBe(false);
232
+ game.close();
233
+ other.game.close();
234
+ });