@serverkgg/bridge 0.0.12 → 0.0.13
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 +9 -1
- package/src/catalogs/curseforge.test.ts +580 -0
- package/src/catalogs/curseforge.ts +461 -0
- package/src/catalogs/index.ts +2 -0
- package/src/catalogs/thunderstore.test.ts +94 -0
- package/src/catalogs/thunderstore.ts +239 -0
- package/src/codecs/index.ts +3 -0
- package/src/codecs/ini.test.ts +152 -0
- package/src/codecs/ini.ts +158 -0
- package/src/codecs/sourceCfg.test.ts +100 -0
- package/src/codecs/sourceCfg.ts +111 -0
- package/src/codecs/xmlProperties.test.ts +104 -0
- package/src/codecs/xmlProperties.ts +133 -0
- package/src/context/bridgeContext.ts +20 -0
- package/src/core/bridgeRconError.ts +19 -0
- package/src/core/index.ts +1 -0
- package/src/install/index.ts +1 -0
- package/src/install/installStamp.test.ts +125 -0
- package/src/install/installStamp.ts +40 -0
- package/src/main.ts +1 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +427 -2
- package/src/manifest/manifest.ts +141 -13
- package/src/manifest/primitives.ts +6 -0
- package/src/manifest/runtime.ts +89 -0
- package/src/net/index.ts +2 -0
- package/src/net/loopback.test.ts +152 -0
- package/src/net/loopback.ts +109 -0
- package/src/net/loopbackMethod.ts +7 -0
- package/src/presence/index.ts +1 -0
- package/src/presence/rosterSync.test.ts +179 -0
- package/src/presence/rosterSync.ts +57 -0
- package/src/protocol/bridgeEvents.ts +4 -0
- package/src/rcon/index.ts +3 -0
- package/src/rcon/sourceRcon.test.ts +308 -0
- package/src/rcon/sourceRcon.ts +276 -0
- package/src/rcon/sourceRconFrame.test.ts +152 -0
- package/src/rcon/sourceRconFrame.ts +99 -0
- package/src/runtime/bridgeCodec.ts +45 -2
- package/src/runtime/bridgeContext.ts +13 -6
- package/src/runtime/bridgeDriver.test.ts +1 -0
- package/src/runtime/bridgeLiveConfig.test.ts +149 -0
- package/src/runtime/bridgeLiveConfig.ts +63 -0
- package/src/runtime/bridgeRcon.ts +15 -0
- package/src/runtime/bridgeRegistry.ts +1 -0
- package/src/steam/index.ts +7 -0
- package/src/steam/steamApp.test.ts +78 -0
- package/src/steam/steamApp.ts +35 -0
- package/src/steam/steamcmd.test.ts +157 -0
- package/src/steam/steamcmd.ts +273 -0
- package/src/steam/steamcmdLogin.ts +34 -0
- package/src/steam/steamcmdNarrator.test.ts +90 -0
- package/src/steam/steamcmdNarrator.ts +43 -0
- package/src/steam/steamcmdPlatform.ts +4 -0
- package/src/steam/webApi.ts +70 -0
- package/src/steam/workshop.test.ts +45 -0
- package/src/steam/workshop.ts +148 -0
- package/src/utils/execDetail.ts +16 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/token.test.ts +50 -0
- package/src/utils/token.ts +35 -0
- package/src/wine/index.ts +3 -0
- package/src/wine/protonCommand.test.ts +67 -0
- package/src/wine/protonCommand.ts +49 -0
- package/src/wine/wineCommand.test.ts +92 -0
- package/src/wine/wineCommand.ts +54 -0
- package/src/wine/winePrefix.test.ts +111 -0
- package/src/wine/winePrefix.ts +88 -0
- package/src/runtime/bridgeSecrets.test.ts +0 -121
- package/src/runtime/bridgeSecrets.ts +0 -43
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Manifest } from "./manifest";
|
|
3
|
+
|
|
4
|
+
export enum ManifestPlatform {
|
|
5
|
+
Linux = "linux",
|
|
6
|
+
Wine = "wine",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export enum ManifestCapability {
|
|
10
|
+
SysNice = "SYS_NICE",
|
|
11
|
+
IpcLock = "IPC_LOCK",
|
|
12
|
+
SysPtrace = "SYS_PTRACE",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const INSTALL_BUDGET_MIN_MINUTES = 5;
|
|
16
|
+
|
|
17
|
+
export const INSTALL_BUDGET_MAX_MINUTES = 180;
|
|
18
|
+
|
|
19
|
+
export const INSTALL_BUDGET_DEFAULT_MINUTES = 15;
|
|
20
|
+
|
|
21
|
+
export const BOOT_BUDGET_MIN_MINUTES = 2;
|
|
22
|
+
|
|
23
|
+
export const BOOT_BUDGET_MAX_MINUTES = 60;
|
|
24
|
+
|
|
25
|
+
export const BOOT_BUDGET_DEFAULT_MINUTES = 12;
|
|
26
|
+
|
|
27
|
+
export const SHM_MIN_MB = 64;
|
|
28
|
+
|
|
29
|
+
export const SHM_MAX_MB = 4096;
|
|
30
|
+
|
|
31
|
+
export const SHM_DEFAULT_MB = 64;
|
|
32
|
+
|
|
33
|
+
export const WINE_MIN_SHM_MB = 256;
|
|
34
|
+
|
|
35
|
+
export const PIDS_LIMIT_MIN = 128;
|
|
36
|
+
|
|
37
|
+
export const PIDS_LIMIT_MAX = 4096;
|
|
38
|
+
|
|
39
|
+
export const PIDS_LIMIT_DEFAULT = 512;
|
|
40
|
+
|
|
41
|
+
export const MANIFEST_CAPABILITIES_LIMIT = 3;
|
|
42
|
+
|
|
43
|
+
export const manifestRuntimeSchema = z.object({
|
|
44
|
+
platform: z.enum(ManifestPlatform).default(ManifestPlatform.Linux).meta({
|
|
45
|
+
description:
|
|
46
|
+
"Whether the game binary is a Linux executable or a Windows one the image runs through Wine or Proton.",
|
|
47
|
+
}),
|
|
48
|
+
installBudgetMinutes: z
|
|
49
|
+
.int()
|
|
50
|
+
.min(INSTALL_BUDGET_MIN_MINUTES)
|
|
51
|
+
.max(INSTALL_BUDGET_MAX_MINUTES)
|
|
52
|
+
.default(INSTALL_BUDGET_DEFAULT_MINUTES)
|
|
53
|
+
.meta({
|
|
54
|
+
description: "How long the first install may take before the platform gives up on it, in minutes.",
|
|
55
|
+
}),
|
|
56
|
+
bootBudgetMinutes: z
|
|
57
|
+
.int()
|
|
58
|
+
.min(BOOT_BUDGET_MIN_MINUTES)
|
|
59
|
+
.max(BOOT_BUDGET_MAX_MINUTES)
|
|
60
|
+
.default(BOOT_BUDGET_DEFAULT_MINUTES)
|
|
61
|
+
.meta({
|
|
62
|
+
description: "How long the game may take to report itself running after the install, in minutes.",
|
|
63
|
+
}),
|
|
64
|
+
shmMb: z
|
|
65
|
+
.int()
|
|
66
|
+
.min(SHM_MIN_MB)
|
|
67
|
+
.max(SHM_MAX_MB)
|
|
68
|
+
.default(SHM_DEFAULT_MB)
|
|
69
|
+
.meta({
|
|
70
|
+
description: `Size of /dev/shm inside the container, in MB. Wine and Proton games need at least ${WINE_MIN_SHM_MB}.`,
|
|
71
|
+
}),
|
|
72
|
+
pidsLimit: z.int().min(PIDS_LIMIT_MIN).max(PIDS_LIMIT_MAX).default(PIDS_LIMIT_DEFAULT).meta({
|
|
73
|
+
description: "How many processes and threads the container may hold at once.",
|
|
74
|
+
}),
|
|
75
|
+
capabilities: z.array(z.enum(ManifestCapability)).max(MANIFEST_CAPABILITIES_LIMIT).default([]).meta({
|
|
76
|
+
description: "Linux capabilities added back on top of the dropped set, from the allow-list this schema declares.",
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export type ManifestRuntime = z.infer<typeof manifestRuntimeSchema>;
|
|
81
|
+
|
|
82
|
+
export const MANIFEST_RUNTIME_DEFAULTS: ManifestRuntime = manifestRuntimeSchema.parse({});
|
|
83
|
+
|
|
84
|
+
export const manifestRuntimeOf = (manifest: Manifest): ManifestRuntime => {
|
|
85
|
+
return {
|
|
86
|
+
...MANIFEST_RUNTIME_DEFAULTS,
|
|
87
|
+
...manifest.container.runtime,
|
|
88
|
+
};
|
|
89
|
+
};
|
package/src/net/index.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { BridgeNetError } from "../core";
|
|
3
|
+
import { loopbackJson, loopbackText } from "./loopback";
|
|
4
|
+
import { LoopbackMethod } from "./loopbackMethod";
|
|
5
|
+
|
|
6
|
+
const serve = (handler: (request: Request) => Response | Promise<Response>) => {
|
|
7
|
+
return Bun.serve({
|
|
8
|
+
hostname: "127.0.0.1",
|
|
9
|
+
port: 0,
|
|
10
|
+
fetch: handler,
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("loopbackJson", () => {
|
|
15
|
+
test("reads a json body from a loopback server", async () => {
|
|
16
|
+
const server = serve(() =>
|
|
17
|
+
Response.json({
|
|
18
|
+
online: 3,
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
expect(
|
|
24
|
+
await loopbackJson<{
|
|
25
|
+
online: number;
|
|
26
|
+
}>(`http://127.0.0.1:${server.port}/state`),
|
|
27
|
+
).toEqual({
|
|
28
|
+
online: 3,
|
|
29
|
+
});
|
|
30
|
+
} finally {
|
|
31
|
+
await server.stop(true);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("sends the method, headers and body it is given", async () => {
|
|
36
|
+
let seen = "";
|
|
37
|
+
|
|
38
|
+
const server = serve(async (request) => {
|
|
39
|
+
seen = `${request.method} ${request.headers.get("x-token") ?? ""} ${await request.text()}`;
|
|
40
|
+
|
|
41
|
+
return Response.json({
|
|
42
|
+
ok: true,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await loopbackJson(`http://127.0.0.1:${server.port}/run`, {
|
|
48
|
+
method: LoopbackMethod.Post,
|
|
49
|
+
headers: {
|
|
50
|
+
"x-token": "abc",
|
|
51
|
+
},
|
|
52
|
+
body: '{"command":"save"}',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(seen).toBe('POST abc {"command":"save"}');
|
|
56
|
+
} finally {
|
|
57
|
+
await server.stop(true);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("raises a net error carrying the status when the server refuses", async () => {
|
|
62
|
+
const server = serve(
|
|
63
|
+
() =>
|
|
64
|
+
new Response("nope", {
|
|
65
|
+
status: 401,
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const error = await loopbackJson(`http://127.0.0.1:${server.port}/`).catch((reason: unknown) => reason);
|
|
71
|
+
|
|
72
|
+
expect(error).toBeInstanceOf(BridgeNetError);
|
|
73
|
+
expect((error as BridgeNetError).status).toBe(401);
|
|
74
|
+
} finally {
|
|
75
|
+
await server.stop(true);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("raises a net error when the body is not json", async () => {
|
|
80
|
+
const server = serve(() => new Response("not json"));
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const error = await loopbackJson(`http://127.0.0.1:${server.port}/`).catch((reason: unknown) => reason);
|
|
84
|
+
|
|
85
|
+
expect(error).toBeInstanceOf(BridgeNetError);
|
|
86
|
+
expect((error as BridgeNetError).status).toBeNull();
|
|
87
|
+
} finally {
|
|
88
|
+
await server.stop(true);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("times out against a server that never answers", async () => {
|
|
93
|
+
const server = serve(async () => {
|
|
94
|
+
await Bun.sleep(600);
|
|
95
|
+
|
|
96
|
+
return new Response("late");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const error = await loopbackText(`http://127.0.0.1:${server.port}/`, {
|
|
101
|
+
timeoutMs: 150,
|
|
102
|
+
}).catch((reason: unknown) => reason);
|
|
103
|
+
|
|
104
|
+
expect(error).toBeInstanceOf(BridgeNetError);
|
|
105
|
+
} finally {
|
|
106
|
+
await server.stop(true);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("loopback guard", () => {
|
|
112
|
+
test("refuses a host that is not loopback", async () => {
|
|
113
|
+
for (const url of [
|
|
114
|
+
"https://api.curseforge.com/v1",
|
|
115
|
+
"http://10.0.0.5:8080/",
|
|
116
|
+
"http://127.0.0.1.evil.test/",
|
|
117
|
+
]) {
|
|
118
|
+
const error = await loopbackText(url).catch((reason: unknown) => reason);
|
|
119
|
+
|
|
120
|
+
expect(error).toBeInstanceOf(BridgeNetError);
|
|
121
|
+
expect((error as BridgeNetError).message).toContain("is not a loopback host");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("refuses a scheme that is not http", async () => {
|
|
126
|
+
const error = await loopbackText("file:///etc/passwd").catch((reason: unknown) => reason);
|
|
127
|
+
|
|
128
|
+
expect(error).toBeInstanceOf(BridgeNetError);
|
|
129
|
+
expect((error as BridgeNetError).message).toContain("is not an http url");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("refuses a header carrying a line break", async () => {
|
|
133
|
+
const error = await loopbackText("http://127.0.0.1:1/", {
|
|
134
|
+
headers: {
|
|
135
|
+
"x-token": "a\r\nx-admin: yes",
|
|
136
|
+
},
|
|
137
|
+
}).catch((reason: unknown) => reason);
|
|
138
|
+
|
|
139
|
+
expect(error).toBeInstanceOf(BridgeNetError);
|
|
140
|
+
expect((error as BridgeNetError).message).toContain("line break");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("accepts localhost", async () => {
|
|
144
|
+
const server = serve(() => new Response("pong"));
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
expect(await loopbackText(`http://localhost:${server.port}/`)).toBe("pong");
|
|
148
|
+
} finally {
|
|
149
|
+
await server.stop(true);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { BridgeNetError } from "../core";
|
|
2
|
+
import { LoopbackMethod } from "./loopbackMethod";
|
|
3
|
+
|
|
4
|
+
const LOOPBACK_TIMEOUT_MS = 10_000;
|
|
5
|
+
|
|
6
|
+
const LOOPBACK_HOSTS = [
|
|
7
|
+
"127.0.0.1",
|
|
8
|
+
"localhost",
|
|
9
|
+
"::1",
|
|
10
|
+
"[::1]",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const LINE_BREAK = /[\r\n]/;
|
|
14
|
+
|
|
15
|
+
export interface LoopbackOptions {
|
|
16
|
+
method?: LoopbackMethod;
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
body?: string;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
insecureTls?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const loopbackUrl = (url: string) => {
|
|
24
|
+
let remote: URL;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
remote = new URL(url);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new BridgeNetError(null, `"${url}" is not a url`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (remote.protocol !== "http:" && remote.protocol !== "https:") {
|
|
33
|
+
throw new BridgeNetError(null, `${remote.protocol} is not an http url`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!LOOPBACK_HOSTS.includes(remote.hostname.toLowerCase())) {
|
|
37
|
+
throw new BridgeNetError(null, `${remote.hostname} is not a loopback host`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return remote;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const loopbackHeaders = (headers: Record<string, string> = {}) => {
|
|
44
|
+
const checked: Record<string, string> = {};
|
|
45
|
+
|
|
46
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
47
|
+
if (LINE_BREAK.test(key) || LINE_BREAK.test(value)) {
|
|
48
|
+
throw new BridgeNetError(null, "a header contains a line break");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
checked[key] = value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return checked;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const loopbackFetch = async (url: string, options: LoopbackOptions) => {
|
|
58
|
+
const remote = loopbackUrl(url);
|
|
59
|
+
const method = options.method ?? LoopbackMethod.Get;
|
|
60
|
+
const body = method === LoopbackMethod.Get || method === LoopbackMethod.Delete ? null : (options.body ?? null);
|
|
61
|
+
|
|
62
|
+
let response: Response;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
response = await fetch(remote, {
|
|
66
|
+
method,
|
|
67
|
+
headers: loopbackHeaders(options.headers),
|
|
68
|
+
...(body === null
|
|
69
|
+
? {}
|
|
70
|
+
: {
|
|
71
|
+
body,
|
|
72
|
+
}),
|
|
73
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? LOOPBACK_TIMEOUT_MS),
|
|
74
|
+
...(options.insecureTls === true
|
|
75
|
+
? {
|
|
76
|
+
tls: {
|
|
77
|
+
rejectUnauthorized: false,
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const detail = error instanceof Error && error.message.length > 0 ? ` — ${error.message}` : "";
|
|
84
|
+
|
|
85
|
+
throw new BridgeNetError(null, `${remote.host} could not be reached${detail}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
await response.text();
|
|
90
|
+
|
|
91
|
+
throw new BridgeNetError(response.status, `${remote.host} answered ${response.status}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return response;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const loopbackText = async (url: string, options: LoopbackOptions = {}): Promise<string> => {
|
|
98
|
+
return await (await loopbackFetch(url, options)).text();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const loopbackJson = async <Result>(url: string, options: LoopbackOptions = {}): Promise<Result> => {
|
|
102
|
+
const text = await loopbackText(url, options);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(text) as Result;
|
|
106
|
+
} catch {
|
|
107
|
+
throw new BridgeNetError(null, `${new URL(url).host} did not answer with json`);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./rosterSync";
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { BridgeContext } from "../context";
|
|
3
|
+
import type { BridgeValues } from "../core";
|
|
4
|
+
import { BridgeEventName } from "../protocol";
|
|
5
|
+
import { createRosterSync } from "./rosterSync";
|
|
6
|
+
|
|
7
|
+
interface Player {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Emitted {
|
|
13
|
+
event: string;
|
|
14
|
+
payload: BridgeValues | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const emitted: Emitted[] = [];
|
|
18
|
+
|
|
19
|
+
const context = {
|
|
20
|
+
emit(event: string, payload?: BridgeValues) {
|
|
21
|
+
emitted.push({
|
|
22
|
+
event,
|
|
23
|
+
payload,
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
} as unknown as BridgeContext;
|
|
27
|
+
|
|
28
|
+
const roster = () => {
|
|
29
|
+
return createRosterSync<Player>({
|
|
30
|
+
id: (player) => player.id,
|
|
31
|
+
presenceOf: (player) => {
|
|
32
|
+
return {
|
|
33
|
+
player: player.name,
|
|
34
|
+
steamId: player.id,
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
emitted.length = 0;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("createRosterSync", () => {
|
|
45
|
+
test("emits a join for every player of the first sample", () => {
|
|
46
|
+
const sync = roster();
|
|
47
|
+
|
|
48
|
+
sync.sync(context, [
|
|
49
|
+
{
|
|
50
|
+
id: "1",
|
|
51
|
+
name: "sara",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "2",
|
|
55
|
+
name: "omar",
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
expect(emitted).toEqual([
|
|
60
|
+
{
|
|
61
|
+
event: BridgeEventName.PlayerJoined,
|
|
62
|
+
payload: {
|
|
63
|
+
player: "sara",
|
|
64
|
+
steamId: "1",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
event: BridgeEventName.PlayerJoined,
|
|
69
|
+
payload: {
|
|
70
|
+
player: "omar",
|
|
71
|
+
steamId: "2",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("stays quiet while the roster does not change", () => {
|
|
78
|
+
const sync = roster();
|
|
79
|
+
const rows = [
|
|
80
|
+
{
|
|
81
|
+
id: "1",
|
|
82
|
+
name: "sara",
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
sync.sync(context, rows);
|
|
87
|
+
emitted.length = 0;
|
|
88
|
+
sync.sync(context, rows);
|
|
89
|
+
|
|
90
|
+
expect(emitted).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("emits a leave carrying the presence the player had", () => {
|
|
94
|
+
const sync = roster();
|
|
95
|
+
|
|
96
|
+
sync.sync(context, [
|
|
97
|
+
{
|
|
98
|
+
id: "1",
|
|
99
|
+
name: "sara",
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
emitted.length = 0;
|
|
103
|
+
sync.sync(context, []);
|
|
104
|
+
|
|
105
|
+
expect(emitted).toEqual([
|
|
106
|
+
{
|
|
107
|
+
event: BridgeEventName.PlayerLeft,
|
|
108
|
+
payload: {
|
|
109
|
+
player: "sara",
|
|
110
|
+
steamId: "1",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("emits both sides of a swap in one sample", () => {
|
|
117
|
+
const sync = roster();
|
|
118
|
+
|
|
119
|
+
sync.sync(context, [
|
|
120
|
+
{
|
|
121
|
+
id: "1",
|
|
122
|
+
name: "sara",
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
emitted.length = 0;
|
|
126
|
+
sync.sync(context, [
|
|
127
|
+
{
|
|
128
|
+
id: "2",
|
|
129
|
+
name: "omar",
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
expect(emitted.map((entry) => entry.event)).toEqual([
|
|
134
|
+
BridgeEventName.PlayerJoined,
|
|
135
|
+
BridgeEventName.PlayerLeft,
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("reports who it believes is online", () => {
|
|
140
|
+
const sync = roster();
|
|
141
|
+
|
|
142
|
+
sync.sync(context, [
|
|
143
|
+
{
|
|
144
|
+
id: "1",
|
|
145
|
+
name: "sara",
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
expect(sync.online()).toEqual([
|
|
150
|
+
{
|
|
151
|
+
id: "1",
|
|
152
|
+
name: "sara",
|
|
153
|
+
},
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
sync.clear();
|
|
157
|
+
|
|
158
|
+
expect(sync.online()).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("emits a fresh join after a clear", () => {
|
|
162
|
+
const sync = roster();
|
|
163
|
+
const rows = [
|
|
164
|
+
{
|
|
165
|
+
id: "1",
|
|
166
|
+
name: "sara",
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
sync.sync(context, rows);
|
|
171
|
+
sync.clear();
|
|
172
|
+
emitted.length = 0;
|
|
173
|
+
sync.sync(context, rows);
|
|
174
|
+
|
|
175
|
+
expect(emitted.map((entry) => entry.event)).toEqual([
|
|
176
|
+
BridgeEventName.PlayerJoined,
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { BridgeContext } from "../context";
|
|
2
|
+
import type { BridgeValues } from "../core";
|
|
3
|
+
import { BridgeEventName } from "../protocol";
|
|
4
|
+
|
|
5
|
+
export interface RosterSyncOptions<Row> {
|
|
6
|
+
id(row: Row): string;
|
|
7
|
+
presenceOf(row: Row): BridgeValues;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RosterSync<Row> {
|
|
11
|
+
sync(context: BridgeContext, rows: Row[]): void;
|
|
12
|
+
clear(): void;
|
|
13
|
+
online(): Row[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const createRosterSync = <Row>(options: RosterSyncOptions<Row>): RosterSync<Row> => {
|
|
17
|
+
const present = new Map<string, Row>();
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
sync(context, rows) {
|
|
21
|
+
const current = new Map(
|
|
22
|
+
rows.map((row) => [
|
|
23
|
+
options.id(row),
|
|
24
|
+
row,
|
|
25
|
+
]),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
for (const [id, row] of current) {
|
|
29
|
+
if (!present.has(id)) {
|
|
30
|
+
context.emit(BridgeEventName.PlayerJoined, options.presenceOf(row));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const [id, row] of present) {
|
|
35
|
+
if (!current.has(id)) {
|
|
36
|
+
context.emit(BridgeEventName.PlayerLeft, options.presenceOf(row));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
present.clear();
|
|
41
|
+
|
|
42
|
+
for (const [id, row] of current) {
|
|
43
|
+
present.set(id, row);
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
clear() {
|
|
48
|
+
present.clear();
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
online() {
|
|
52
|
+
return [
|
|
53
|
+
...present.values(),
|
|
54
|
+
];
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
@@ -3,14 +3,18 @@ export enum BridgeEventName {
|
|
|
3
3
|
MemoryUndersized = "MemoryUndersized",
|
|
4
4
|
MissingDependency = "MissingDependency",
|
|
5
5
|
ModCrashed = "ModCrashed",
|
|
6
|
+
ModLoaded = "ModLoaded",
|
|
6
7
|
PlayerAdvanced = "PlayerAdvanced",
|
|
8
|
+
PlayerBanned = "PlayerBanned",
|
|
7
9
|
PlayerDied = "PlayerDied",
|
|
8
10
|
PlayerJoined = "PlayerJoined",
|
|
11
|
+
PlayerKicked = "PlayerKicked",
|
|
9
12
|
PlayerLeft = "PlayerLeft",
|
|
10
13
|
PortBindFailed = "PortBindFailed",
|
|
11
14
|
ServerCrashed = "ServerCrashed",
|
|
12
15
|
ServerStarted = "ServerStarted",
|
|
13
16
|
ServerStopping = "ServerStopping",
|
|
17
|
+
ServerUpdated = "ServerUpdated",
|
|
14
18
|
TickLagging = "TickLagging",
|
|
15
19
|
WorldCorrupt = "WorldCorrupt",
|
|
16
20
|
WorldSaved = "WorldSaved",
|