@serverkgg/bridge 0.1.11 → 0.1.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { createLineSpacing } from "./bridgeLineSpacing";
3
+
4
+ const shown = (lines: string[]) => {
5
+ const spacing = createLineSpacing();
6
+
7
+ return lines.flatMap((line) => spacing(line));
8
+ };
9
+
10
+ describe("createLineSpacing", () => {
11
+ it("drops the blank a game prints after every line once the pattern is clear", () => {
12
+ expect(
13
+ shown([
14
+ "[LOG] one",
15
+ "",
16
+ "[LOG] two",
17
+ "",
18
+ "[LOG] three",
19
+ "",
20
+ "[LOG] four",
21
+ "",
22
+ "[LOG] five",
23
+ "",
24
+ ]),
25
+ ).toEqual([
26
+ "[LOG] one",
27
+ "",
28
+ "[LOG] two",
29
+ "",
30
+ "[LOG] three",
31
+ "[LOG] four",
32
+ "[LOG] five",
33
+ ]);
34
+ });
35
+
36
+ it("keeps a single blank line a single-spaced game uses to separate blocks", () => {
37
+ expect(
38
+ shown([
39
+ "Loading world",
40
+ "Loading players",
41
+ "",
42
+ "Server ready",
43
+ ]),
44
+ ).toEqual([
45
+ "Loading world",
46
+ "Loading players",
47
+ "",
48
+ "Server ready",
49
+ ]);
50
+ });
51
+
52
+ it("collapses a run of blank lines into one", () => {
53
+ expect(
54
+ shown([
55
+ "banner",
56
+ "",
57
+ "",
58
+ "",
59
+ "first log line",
60
+ ]),
61
+ ).toEqual([
62
+ "banner",
63
+ "",
64
+ "first log line",
65
+ ]);
66
+ });
67
+
68
+ it("still shows a deliberate break inside a double-spaced stream", () => {
69
+ expect(
70
+ shown([
71
+ "a",
72
+ "",
73
+ "b",
74
+ "",
75
+ "c",
76
+ "",
77
+ "d",
78
+ "",
79
+ "",
80
+ "e",
81
+ ]),
82
+ ).toEqual([
83
+ "a",
84
+ "",
85
+ "b",
86
+ "",
87
+ "c",
88
+ "d",
89
+ "",
90
+ "e",
91
+ ]);
92
+ });
93
+
94
+ it("drops blank lines before the first line and whitespace-only lines", () => {
95
+ expect(
96
+ shown([
97
+ "",
98
+ " ",
99
+ "first",
100
+ ]),
101
+ ).toEqual([
102
+ "first",
103
+ ]);
104
+ });
105
+
106
+ it("goes back to keeping single blanks when the game starts writing lines back to back", () => {
107
+ expect(
108
+ shown([
109
+ "a",
110
+ "",
111
+ "b",
112
+ "",
113
+ "c",
114
+ "",
115
+ "d",
116
+ "e",
117
+ "",
118
+ "f",
119
+ ]),
120
+ ).toEqual([
121
+ "a",
122
+ "",
123
+ "b",
124
+ "",
125
+ "c",
126
+ "d",
127
+ "e",
128
+ "",
129
+ "f",
130
+ ]);
131
+ });
132
+ });
@@ -0,0 +1,49 @@
1
+ const DOUBLE_SPACED_RUN = 3;
2
+
3
+ export type BridgeLineSpacing = (line: string) => string[];
4
+
5
+ export const createLineSpacing = (): BridgeLineSpacing => {
6
+ const gaps: number[] = [];
7
+
8
+ let pending = 0;
9
+ let started = false;
10
+
11
+ const doubleSpaced = () => {
12
+ return gaps.length === DOUBLE_SPACED_RUN && gaps.every((gap) => gap === 1);
13
+ };
14
+
15
+ return (line) => {
16
+ if (line.trim().length === 0) {
17
+ if (started) {
18
+ pending += 1;
19
+ }
20
+
21
+ return [];
22
+ }
23
+
24
+ const gap = pending;
25
+ const first = !started;
26
+
27
+ if (started) {
28
+ gaps.push(gap);
29
+
30
+ if (gaps.length > DOUBLE_SPACED_RUN) {
31
+ gaps.shift();
32
+ }
33
+ }
34
+
35
+ started = true;
36
+ pending = 0;
37
+
38
+ if (first || gap === 0 || (gap === 1 && doubleSpaced())) {
39
+ return [
40
+ line,
41
+ ];
42
+ }
43
+
44
+ return [
45
+ "",
46
+ line,
47
+ ];
48
+ };
49
+ };
@@ -4,6 +4,7 @@ import { BridgeLifecycleSubject } from "../modules/bridgeLifecycleSubject";
4
4
  import { BRIDGE_STOP_SIGNALS, type BridgeStopSignal } from "../modules/bridgeStopSignal";
5
5
  import { BRIDGE_CRASH_LINE_LIMIT, BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
6
6
  import { stripAnsi } from "../utils";
7
+ import { createLineSpacing } from "./bridgeLineSpacing";
7
8
  import type { BridgeLeveledLogger } from "./bridgeLog";
8
9
  import { errorText } from "./bridgeLog";
9
10
  import { settleUid, unprivilegedArgv } from "./bridgePrivilege";
@@ -306,6 +307,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
306
307
  const pump = async (stream: ReadableStream<Uint8Array>, kind: BridgeStream) => {
307
308
  const decoder = new TextDecoder();
308
309
  const reader = stream.getReader();
310
+ const spacing = createLineSpacing();
309
311
 
310
312
  let buffer = "";
311
313
 
@@ -325,7 +327,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
325
327
 
326
328
  buffer = split.rest;
327
329
 
328
- for (const line of split.lines) {
330
+ for (const line of split.lines.flatMap(spacing)) {
329
331
  emit({
330
332
  stream: kind,
331
333
  line,
@@ -333,10 +335,10 @@ export const createSupervisor = (options: SupervisorOptions) => {
333
335
  }
334
336
  }
335
337
 
336
- if (buffer.length > 0) {
338
+ for (const line of spacing(buffer)) {
337
339
  emit({
338
340
  stream: kind,
339
- line: buffer,
341
+ line,
340
342
  });
341
343
  }
342
344
  };
@@ -20,7 +20,7 @@ const recorder = () => {
20
20
  };
21
21
 
22
22
  describe("steamcmdNarrator", () => {
23
- it("reports download progress in ten percent steps", () => {
23
+ it("reports download progress in ten percent steps, each line with the percent steamcmd printed", () => {
24
24
  const { log, lines, fields } = recorder();
25
25
  const narrate = steamcmdNarrator(log, "palworld");
26
26
 
@@ -36,10 +36,10 @@ describe("steamcmdNarrator", () => {
36
36
  }
37
37
 
38
38
  expect(lines).toEqual([
39
- "downloading palworld",
40
- "downloading palworld",
41
- "downloading palworld",
42
- "downloading palworld",
39
+ "downloading palworld 2%",
40
+ "downloading palworld 11%",
41
+ "downloading palworld 25%",
42
+ "downloading palworld 99%",
43
43
  ]);
44
44
  expect(fields).toEqual([
45
45
  {
@@ -74,6 +74,19 @@ describe("steamcmdNarrator", () => {
74
74
  ]);
75
75
  });
76
76
 
77
+ it("says how much of the download is done when steamcmd prints the byte counts", () => {
78
+ const { log, lines } = recorder();
79
+ const narrate = steamcmdNarrator(log, "palworld");
80
+
81
+ narrate("Update state (0x61) downloading, progress: 1.11 (127644881 / 11530459441)");
82
+ narrate("Update state (0x61) downloading, progress: 40.02 (4614180864 / 11530459441)");
83
+
84
+ expect(lines).toEqual([
85
+ "downloading palworld 1% (122 MB of 10.7 GB)",
86
+ "downloading palworld 40% (4.3 GB of 10.7 GB)",
87
+ ]);
88
+ });
89
+
77
90
  it("passes notable lines through with the ansi stripped", () => {
78
91
  const { log, lines } = recorder();
79
92
  const narrate = steamcmdNarrator(log, "rust");
@@ -1,12 +1,33 @@
1
1
  import type { BridgeLog } from "../context";
2
2
  import { stripAnsi } from "../utils";
3
3
 
4
- const PROGRESS = /Update state \(0x[0-9a-f]+\)\s*(?<phase>[^,]+),\s*progress:\s*(?<percent>\d+)\.\d+/i;
4
+ const PROGRESS =
5
+ /Update state \(0x[0-9a-f]+\)\s*(?<phase>[^,]+),\s*progress:\s*(?<percent>\d+)\.\d+(?:\s*\((?<done>\d+)\s*\/\s*(?<total>\d+)\))?/i;
6
+
7
+ const GIGABYTE = 1024 ** 3;
8
+
9
+ const MEGABYTE = 1024 ** 2;
5
10
 
6
11
  const PROGRESS_STEP = 10;
7
12
 
8
13
  const NOTABLE = /^(Error|Warning|Failed|Success|Logging in|Connecting|Update state \(0x\d+\) (?!downloading))/i;
9
14
 
15
+ const byteSizeOf = (bytes: number) => {
16
+ if (bytes >= GIGABYTE) {
17
+ return `${(bytes / GIGABYTE).toFixed(1)} GB`;
18
+ }
19
+
20
+ return `${Math.round(bytes / MEGABYTE)} MB`;
21
+ };
22
+
23
+ const progressLineOf = (phase: string, label: string, percent: number, done: number, total: number) => {
24
+ if (total < MEGABYTE) {
25
+ return `${phase} ${label} ${percent}%`;
26
+ }
27
+
28
+ return `${phase} ${label} ${percent}% (${byteSizeOf(done)} of ${byteSizeOf(total)})`;
29
+ };
30
+
10
31
  export const steamcmdNarrator = (log: BridgeLog, label: string) => {
11
32
  let phase = "";
12
33
  let reported = -1;
@@ -28,7 +49,10 @@ export const steamcmdNarrator = (log: BridgeLog, label: string) => {
28
49
  if (step > reported) {
29
50
  reported = step;
30
51
 
31
- log(`${phase} ${label}`, {
52
+ const done = Number(progress.groups.done ?? 0);
53
+ const total = Number(progress.groups.total ?? 0);
54
+
55
+ log(progressLineOf(phase, label, percent, done, total), {
32
56
  percent: step,
33
57
  });
34
58
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { BridgeContext } from "../context";
3
- import { protonCommand, protonCompatPath } from "./protonCommand";
3
+ import { ProtonVerb, protonCommand, protonCompatPath } from "./protonCommand";
4
4
 
5
5
  const ensured: string[] = [];
6
6
 
@@ -41,6 +41,22 @@ describe("protonCommand", () => {
41
41
  ]);
42
42
  });
43
43
 
44
+ it("runs straight in the prefix when asked, so the game keeps the container's stdout", async () => {
45
+ expect(
46
+ (
47
+ await protonCommand(context, {
48
+ appId: "2394010",
49
+ exe: "Pal/Binaries/Win64/PalServer-Win64-Shipping-Cmd.exe",
50
+ verb: ProtonVerb.RunInPrefix,
51
+ })
52
+ ).slice(-3),
53
+ ).toEqual([
54
+ "/opt/proton/proton",
55
+ "runinprefix",
56
+ "Pal/Binaries/Win64/PalServer-Win64-Shipping-Cmd.exe",
57
+ ]);
58
+ });
59
+
44
60
  it("ensures the compat and steam directories", async () => {
45
61
  ensured.length = 0;
46
62
 
@@ -7,6 +7,11 @@ export const PROTON_COMPAT_DIRECTORY = ".proton";
7
7
 
8
8
  export const STEAM_COMPAT_CLIENT_DIRECTORY = ".steam";
9
9
 
10
+ export enum ProtonVerb {
11
+ Run = "run",
12
+ RunInPrefix = "runinprefix",
13
+ }
14
+
10
15
  export interface ProtonCommandOptions {
11
16
  appId: string;
12
17
  exe: string;
@@ -16,6 +21,7 @@ export interface ProtonCommandOptions {
16
21
  steamDir?: string;
17
22
  home?: string;
18
23
  environment?: Record<string, string>;
24
+ verb?: ProtonVerb;
19
25
  }
20
26
 
21
27
  export const protonCompatPath = (appId: string, compatDir: string = PROTON_COMPAT_DIRECTORY) => {
@@ -42,7 +48,7 @@ export const protonCommand = async (context: BridgeContext, options: ProtonComma
42
48
  "env",
43
49
  ...Object.entries(environment).map(([key, value]) => `${key}=${value}`),
44
50
  `${protonDir}/proton`,
45
- "run",
51
+ options.verb ?? ProtonVerb.Run,
46
52
  options.exe,
47
53
  ...(options.args ?? []),
48
54
  ];