@pylonsync/realtime 0.18.0 → 0.18.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/package.json +1 -1
- package/src/shard-restart.e2e.test.ts +150 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.18.
|
|
6
|
+
"version": "0.18.1",
|
|
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",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// End-to-end: a zone's state survives a restart of a `pylon start` process
|
|
2
|
+
// on SQLite (examples/shard-arena's zone). The heal step heals a player,
|
|
3
|
+
// which only the zone's state records (the Character row has no hp); the
|
|
4
|
+
// check step, after the server restarts, finds the healed hp.
|
|
5
|
+
//
|
|
6
|
+
// Runs only when PYLON_SHARD_RESTART_E2E is "<host:port>",
|
|
7
|
+
// PYLON_SHARD_ADMIN_TOKEN is the server's admin token, PYLON_SHARD_RESTART_FILE
|
|
8
|
+
// names a file that carries the zone between steps, and
|
|
9
|
+
// PYLON_SHARD_RESTART_STEP is "heal" or "check" with PYLON_SHARD_RESTART_HP
|
|
10
|
+
// the hp to expect. tools/smoke-wasm-shard.sh runs it.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
import { expect, test } from "bun:test";
|
|
15
|
+
|
|
16
|
+
import { connectShard } from "./connection";
|
|
17
|
+
|
|
18
|
+
const host = process.env.PYLON_SHARD_RESTART_E2E ?? "";
|
|
19
|
+
const adminToken = process.env.PYLON_SHARD_ADMIN_TOKEN ?? "";
|
|
20
|
+
const file = process.env.PYLON_SHARD_RESTART_FILE ?? "";
|
|
21
|
+
const step = process.env.PYLON_SHARD_RESTART_STEP ?? "";
|
|
22
|
+
const hp = Number(process.env.PYLON_SHARD_RESTART_HP ?? "0");
|
|
23
|
+
const enabled = Boolean(host && adminToken && file && step && hp);
|
|
24
|
+
|
|
25
|
+
interface Player {
|
|
26
|
+
hp: number;
|
|
27
|
+
loaded: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Zone {
|
|
31
|
+
players: Record<string, Player>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Joined = { subscriberId: string; ticket: string };
|
|
35
|
+
|
|
36
|
+
/** The zone and the player the steps share. */
|
|
37
|
+
interface Carried {
|
|
38
|
+
zone: string;
|
|
39
|
+
sid: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sleep(ms: number): Promise<void> {
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function guest(): Promise<string> {
|
|
47
|
+
const res = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
|
|
48
|
+
expect(res.ok).toBe(true);
|
|
49
|
+
return ((await res.json()) as { token: string }).token;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function call<T>(token: string, fn: string, args: unknown): Promise<T> {
|
|
53
|
+
const res = await fetch(`http://${host}/api/fn/${fn}`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: {
|
|
56
|
+
authorization: `Bearer ${token}`,
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify(args),
|
|
60
|
+
});
|
|
61
|
+
const text = await res.text();
|
|
62
|
+
if (res.status !== 200) throw new Error(`${fn}: ${res.status} ${text}`);
|
|
63
|
+
return JSON.parse(text) as T;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Watch `zone` as a new guest until player `sid` has `want` hp. The zone can
|
|
68
|
+
* still be starting after a restart: each try joins again.
|
|
69
|
+
*/
|
|
70
|
+
async function waitForHp(zone: string, sid: string, want: number, ms: number) {
|
|
71
|
+
const token = await guest();
|
|
72
|
+
const start = Date.now();
|
|
73
|
+
let seen: number | undefined;
|
|
74
|
+
while (Date.now() - start < ms) {
|
|
75
|
+
let snapshot: Zone | undefined;
|
|
76
|
+
try {
|
|
77
|
+
const joined = await call<Joined>(token, "joinZone", { zone });
|
|
78
|
+
const watcher = connectShard<Zone, unknown>(zone, {
|
|
79
|
+
subscriberId: joined.subscriberId,
|
|
80
|
+
baseUrl: host,
|
|
81
|
+
ticket: async (shard) =>
|
|
82
|
+
(await call<Joined>(token, "joinZone", { zone: shard })).ticket,
|
|
83
|
+
});
|
|
84
|
+
watcher.onSnapshot((s) => (snapshot = s));
|
|
85
|
+
const until = Date.now() + 3000;
|
|
86
|
+
while (Date.now() < until && snapshot?.players[sid]?.hp !== want) {
|
|
87
|
+
await sleep(100);
|
|
88
|
+
}
|
|
89
|
+
watcher.close();
|
|
90
|
+
} catch {
|
|
91
|
+
// The zone is not up yet.
|
|
92
|
+
}
|
|
93
|
+
seen = snapshot?.players[sid]?.hp;
|
|
94
|
+
if (seen === want) return;
|
|
95
|
+
await sleep(500);
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`player ${sid} in ${zone}: hp ${seen}, want ${want}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test.skipIf(!enabled || step !== "heal")(
|
|
101
|
+
"heal: a player's hp, kept only in the zone's state, goes up",
|
|
102
|
+
async () => {
|
|
103
|
+
let carried: Carried;
|
|
104
|
+
if (existsSync(file)) {
|
|
105
|
+
carried = JSON.parse(readFileSync(file, "utf8")) as Carried;
|
|
106
|
+
} else {
|
|
107
|
+
// A new zone and player.
|
|
108
|
+
const zone = `restart-${Math.random().toString(36).slice(2, 8)}`;
|
|
109
|
+
const token = await guest();
|
|
110
|
+
const joined = await call<Joined>(token, "joinZone", { zone });
|
|
111
|
+
let snapshot: Zone | undefined;
|
|
112
|
+
const client = connectShard<Zone, unknown>(zone, {
|
|
113
|
+
subscriberId: joined.subscriberId,
|
|
114
|
+
baseUrl: host,
|
|
115
|
+
ticket: async (shard) =>
|
|
116
|
+
(await call<Joined>(token, "joinZone", { zone: shard })).ticket,
|
|
117
|
+
});
|
|
118
|
+
client.onSnapshot((s) => (snapshot = s));
|
|
119
|
+
const start = Date.now();
|
|
120
|
+
while (!snapshot) {
|
|
121
|
+
if (Date.now() - start > 10_000) throw new Error("no snapshot");
|
|
122
|
+
await sleep(50);
|
|
123
|
+
}
|
|
124
|
+
client.send("join");
|
|
125
|
+
while (!snapshot?.players[joined.subscriberId]?.loaded) {
|
|
126
|
+
if (Date.now() - start > 10_000) throw new Error("the character did not load");
|
|
127
|
+
await sleep(50);
|
|
128
|
+
}
|
|
129
|
+
client.close();
|
|
130
|
+
carried = { zone, sid: joined.subscriberId };
|
|
131
|
+
writeFileSync(file, JSON.stringify(carried));
|
|
132
|
+
}
|
|
133
|
+
await call(adminToken, "gmHeal", {
|
|
134
|
+
zone: carried.zone,
|
|
135
|
+
userId: carried.sid,
|
|
136
|
+
hp: 7,
|
|
137
|
+
});
|
|
138
|
+
await waitForHp(carried.zone, carried.sid, hp, 10_000);
|
|
139
|
+
},
|
|
140
|
+
60_000,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
test.skipIf(!enabled || step !== "check")(
|
|
144
|
+
"check: after the restart, the zone has the healed hp",
|
|
145
|
+
async () => {
|
|
146
|
+
const carried = JSON.parse(readFileSync(file, "utf8")) as Carried;
|
|
147
|
+
await waitForHp(carried.zone, carried.sid, hp, 30_000);
|
|
148
|
+
},
|
|
149
|
+
60_000,
|
|
150
|
+
);
|