@serverkgg/bridge 0.0.21 → 0.1.2

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.
Files changed (119) hide show
  1. package/package.json +3 -1
  2. package/src/bot/botDeploy.test.ts +363 -0
  3. package/src/bot/botDeploy.ts +136 -0
  4. package/src/bot/botEnvironment.test.ts +189 -0
  5. package/src/bot/botEnvironment.ts +141 -0
  6. package/src/bot/botIdentity.test.ts +251 -0
  7. package/src/bot/botIdentity.ts +140 -0
  8. package/src/bot/botPanel.test.ts +246 -0
  9. package/src/bot/botPanel.ts +129 -0
  10. package/src/bot/botState.test.ts +45 -0
  11. package/src/bot/botState.ts +54 -0
  12. package/src/bot/botTerminal.test.ts +79 -0
  13. package/src/bot/botTerminal.ts +62 -0
  14. package/src/bot/botVariables.test.ts +193 -0
  15. package/src/bot/botVariables.ts +73 -0
  16. package/src/bot/detect/botFramework.ts +23 -0
  17. package/src/bot/detect/botFrameworks.ts +198 -0
  18. package/src/bot/detect/botInspectWarning.ts +15 -0
  19. package/src/bot/detect/botPackageManager.ts +6 -0
  20. package/src/bot/detect/botPlatform.ts +10 -0
  21. package/src/bot/detect/botRuntime.ts +15 -0
  22. package/src/bot/detect/botToken.test.ts +71 -0
  23. package/src/bot/detect/botToken.ts +126 -0
  24. package/src/bot/detect/detect.test.ts +543 -0
  25. package/src/bot/detect/detect.ts +842 -0
  26. package/src/bot/detect/detectResult.ts +40 -0
  27. package/src/bot/detect/detectTable.test.ts +72 -0
  28. package/src/bot/detect/detectTable.ts +166 -0
  29. package/src/bot/detect/envExample.test.ts +29 -0
  30. package/src/bot/detect/envExample.ts +48 -0
  31. package/src/bot/detect/envKey.ts +7 -0
  32. package/src/bot/detect/envScan.test.ts +39 -0
  33. package/src/bot/detect/envScan.ts +87 -0
  34. package/src/bot/detect/index.ts +17 -0
  35. package/src/bot/detect/inferBotPlatform.test.ts +60 -0
  36. package/src/bot/detect/inferBotPlatform.ts +28 -0
  37. package/src/bot/detect/keys.test.ts +28 -0
  38. package/src/bot/detect/keys.ts +64 -0
  39. package/src/bot/detect/sensitiveKey.test.ts +91 -0
  40. package/src/bot/detect/sensitiveKey.ts +44 -0
  41. package/src/bot/detect/serverkJson.test.ts +88 -0
  42. package/src/bot/detect/serverkJson.ts +174 -0
  43. package/src/bot/index.ts +8 -0
  44. package/src/cli/detectTable.test.ts +66 -0
  45. package/src/cli/detectTable.ts +43 -0
  46. package/src/cli/main.ts +12 -4
  47. package/src/cli/renderedFile.ts +35 -0
  48. package/src/cli/schema.ts +7 -26
  49. package/src/cli/validate.test.ts +283 -3
  50. package/src/cli/validate.ts +105 -7
  51. package/src/context/bridgeContext.ts +17 -1
  52. package/src/core/bridgeAction.ts +2 -0
  53. package/src/core/bridgeActivity.ts +9 -0
  54. package/src/core/bridgeAvailability.ts +10 -0
  55. package/src/core/bridgePort.test.ts +45 -0
  56. package/src/core/bridgePort.ts +26 -0
  57. package/src/core/bridgeUserError.test.ts +10 -0
  58. package/src/core/bridgeUserError.ts +6 -2
  59. package/src/core/describeDownloadFailure.test.ts +58 -0
  60. package/src/core/describeDownloadFailure.ts +69 -0
  61. package/src/core/index.ts +3 -0
  62. package/src/guides/compile.ts +2 -2
  63. package/src/manifest/index.ts +1 -0
  64. package/src/manifest/kind.ts +5 -0
  65. package/src/manifest/manifest.test.ts +630 -3
  66. package/src/manifest/manifest.ts +391 -87
  67. package/src/manifest/runtime.ts +2 -2
  68. package/src/manifest/source.ts +4 -4
  69. package/src/modules/bridge.ts +8 -1
  70. package/src/modules/bridgeLifecycleSubject.ts +8 -0
  71. package/src/modules/bridgeModule.ts +19 -1
  72. package/src/modules/bridgeStopSignal.ts +13 -0
  73. package/src/modules/index.ts +2 -0
  74. package/src/panel/bridgeDetail.ts +4 -1
  75. package/src/protocol/bridgeActivity.ts +54 -0
  76. package/src/protocol/bridgeConfig.ts +2 -0
  77. package/src/protocol/bridgeEvents.ts +30 -0
  78. package/src/protocol/bridgeFrame.ts +10 -1
  79. package/src/protocol/index.ts +1 -0
  80. package/src/runtime/bridgeActions.ts +19 -2
  81. package/src/runtime/bridgeActivity.test.ts +318 -0
  82. package/src/runtime/bridgeActivity.ts +268 -0
  83. package/src/runtime/bridgeConfigReader.test.ts +80 -0
  84. package/src/runtime/bridgeConfigReader.ts +20 -0
  85. package/src/runtime/bridgeContext.test.ts +247 -0
  86. package/src/runtime/bridgeContext.ts +43 -10
  87. package/src/runtime/bridgeDriver.test.ts +153 -1
  88. package/src/runtime/bridgeDriver.ts +64 -8
  89. package/src/runtime/bridgeFiles.test.ts +62 -0
  90. package/src/runtime/bridgeFiles.ts +12 -2
  91. package/src/runtime/bridgeLiveConfig.ts +18 -0
  92. package/src/runtime/bridgeLog.ts +10 -0
  93. package/src/runtime/bridgeRedaction.test.ts +410 -0
  94. package/src/runtime/bridgeRedaction.ts +214 -0
  95. package/src/runtime/bridgeRefusalError.ts +13 -0
  96. package/src/runtime/bridgeRegistry.test.ts +1056 -10
  97. package/src/runtime/bridgeRegistry.ts +464 -50
  98. package/src/runtime/bridgeScopedContext.ts +55 -0
  99. package/src/runtime/bridgeServer.test.ts +534 -4
  100. package/src/runtime/bridgeServer.ts +145 -38
  101. package/src/runtime/bridgeSetupHandlers.test.ts +156 -5
  102. package/src/runtime/bridgeSetupHandlers.ts +59 -14
  103. package/src/runtime/bridgeSupervisor.test.ts +670 -2
  104. package/src/runtime/bridgeSupervisor.ts +219 -67
  105. package/src/runtime/serve.ts +87 -11
  106. package/src/steam/steamcmd.test.ts +32 -1
  107. package/src/steam/steamcmd.ts +27 -4
  108. package/src/utils/changedSettingKeys.test.ts +65 -0
  109. package/src/utils/changedSettingKeys.ts +6 -0
  110. package/src/utils/describeExecFailure.test.ts +34 -0
  111. package/src/utils/describeExecFailure.ts +9 -0
  112. package/src/utils/index.ts +3 -0
  113. package/src/utils/sameSettingValue.test.ts +35 -0
  114. package/src/utils/sameSettingValue.ts +40 -0
  115. package/src/wire/activity.test.ts +95 -0
  116. package/src/wire/activity.ts +41 -0
  117. package/src/wire/index.ts +1 -0
  118. package/src/wire/panel.test.ts +67 -1
  119. package/src/wire/panel.ts +11 -0
@@ -1,14 +1,16 @@
1
1
  import { chmod, chown, mkdir, rm } from "node:fs/promises";
2
2
  import { BridgeDesired, type BridgeInstallHold, BridgeRuntime, BridgeUserError, type LocalizedText } from "../core";
3
- import type { BridgeDriver } from "../modules";
3
+ import { type BridgeDriver, BridgeLifecycleSubject } from "../modules";
4
4
  import {
5
5
  BRIDGE_INSTALL_ERROR_LIMIT,
6
6
  BRIDGE_VERSION,
7
7
  type BridgeConfig,
8
+ BridgeEventName,
8
9
  type BridgePendingResult,
9
10
  type BridgeSampleResult,
10
11
  type BridgeStatus,
11
12
  } from "../protocol";
13
+ import { type ActivityNormalizeOptions, createActivityReporter } from "./bridgeActivity";
12
14
  import { createBackupLease } from "./bridgeBackupLease";
13
15
  import { configPath, readConfig, socketPath, statePath } from "./bridgeConfigReader";
14
16
  import { createConsoleHandler } from "./bridgeConsoleHandler";
@@ -16,8 +18,10 @@ import { createContext } from "./bridgeContext";
16
18
  import { validateDriver } from "./bridgeDriver";
17
19
  import { createEventRing } from "./bridgeEvents";
18
20
  import { decideInstall, recountHold } from "./bridgeInstall";
19
- import { errorText, log } from "./bridgeLog";
21
+ import { createBridgeLiveConfig } from "./bridgeLiveConfig";
22
+ import { errorDetail, errorText, log as stdoutLog } from "./bridgeLog";
20
23
  import { pendingResult, rejectedPending, unsupportedPending } from "./bridgePending";
24
+ import { createBridgeRedaction, redactLocalized, redactLogger, secretFieldKeys } from "./bridgeRedaction";
21
25
  import { createRegistry } from "./bridgeRegistry";
22
26
  import { createServer } from "./bridgeServer";
23
27
  import { setupSummaryOf } from "./bridgeSetup";
@@ -35,6 +39,17 @@ const DEFAULT_STOP_TIMEOUT_SECONDS = 30;
35
39
 
36
40
  const DEFAULT_SAMPLE_REFRESH_SECONDS = 30;
37
41
 
42
+ const INSTALL_FAILED_TEXT: Record<BridgeLifecycleSubject, LocalizedText> = {
43
+ [BridgeLifecycleSubject.Game]: {
44
+ ar: "ما قدرنا نثبّت اللعبة. جرّب مرة ثانية.",
45
+ en: "We could not install the game. Try again.",
46
+ },
47
+ [BridgeLifecycleSubject.Bot]: {
48
+ ar: "ما قدرنا نثبّت كود بوتك. جرّب مرة ثانية.",
49
+ en: "We could not install your bot's code. Try again.",
50
+ },
51
+ };
52
+
38
53
  const prepare = async (config: BridgeConfig) => {
39
54
  await mkdir(config.cwd, {
40
55
  recursive: true,
@@ -69,25 +84,42 @@ export const serve = async (driver: BridgeDriver) => {
69
84
 
70
85
  const desired = await readDesired(statePath(config));
71
86
 
87
+ const live = createBridgeLiveConfig(configPath(), config);
88
+
89
+ const redact = createBridgeRedaction(secretFieldKeys(driver), live);
90
+
91
+ const log = redactLogger(stdoutLog, redact);
92
+
72
93
  let installError: string | null = null;
73
94
 
74
95
  let installErrorText: LocalizedText | null = null;
75
96
 
76
97
  let installHold: BridgeInstallHold | null = null;
77
98
 
78
- const setup = createSetupHandlers(driver, log);
99
+ const setup = createSetupHandlers(driver, log, redact);
79
100
 
80
101
  const supervisor = createSupervisor({
81
102
  cwd: config.cwd,
82
103
  uid: config.gameUid,
83
104
  gid: config.gameGid,
84
105
  stopTimeoutSeconds: driver.lifecycle.stopTimeoutSeconds ?? DEFAULT_STOP_TIMEOUT_SECONDS,
106
+ readyTimeoutSeconds: driver.lifecycle.readyTimeoutSeconds,
107
+ stopSignal: driver.lifecycle.stopSignal,
108
+ subject: driver.lifecycle.subject,
85
109
  ready: driver.lifecycle.ready,
86
110
  failed: driver.lifecycle.failed,
111
+ redact,
87
112
  onLog: log,
88
113
  async resolveCommand() {
114
+ live.reload();
115
+
89
116
  return await driver.lifecycle.command(context);
90
117
  },
118
+ resolveEnvironment: driver.lifecycle.environment
119
+ ? async () => {
120
+ return (await driver.lifecycle.environment?.(context)) ?? {};
121
+ }
122
+ : undefined,
91
123
  async gracefulStop() {
92
124
  await driver.lifecycle.stop?.(context);
93
125
  },
@@ -111,17 +143,47 @@ export const serve = async (driver: BridgeDriver) => {
111
143
 
112
144
  const ring = createEventRing(driver.events, supervisor);
113
145
 
114
- const context = createContext(config, supervisor, {
115
- onLog: log,
146
+ const activity: ActivityNormalizeOptions = {
147
+ secretKeys: new Set(secretFieldKeys(driver)),
148
+ redact,
149
+ warn: log.warn,
150
+ };
151
+
152
+ const reporter = createActivityReporter({
153
+ ...activity,
116
154
  onEmit(event, payload) {
117
155
  ring.emit(event, payload);
118
156
  },
119
- onSetup(runtime) {
120
- setup.onSetup(runtime);
121
- },
122
157
  });
123
158
 
124
- const registry = createRegistry(driver, context, supervisor, ring);
159
+ const context = createContext(
160
+ config,
161
+ supervisor,
162
+ {
163
+ onLog: log,
164
+ onEmit(event, payload) {
165
+ ring.emit(event, payload);
166
+ },
167
+ onSetup(runtime) {
168
+ setup.onSetup(runtime);
169
+ },
170
+ onActivity(entry) {
171
+ reporter.report(entry);
172
+ },
173
+ async onRestart() {
174
+ if (current !== BridgeDesired.Running) {
175
+ throw new Error(
176
+ `the ${driver.lifecycle.subject ?? BridgeLifecycleSubject.Game} is not meant to be running, so there is nothing to restart`,
177
+ );
178
+ }
179
+
180
+ await supervisor.restart();
181
+ },
182
+ },
183
+ live,
184
+ );
185
+
186
+ const registry = createRegistry(driver, context, supervisor, ring, activity);
125
187
  const backupLease = createBackupLease(async () => {
126
188
  if (supervisor.running) {
127
189
  await driver.backup?.release?.(context);
@@ -220,6 +282,7 @@ export const serve = async (driver: BridgeDriver) => {
220
282
  const server = createServer({
221
283
  socket: socketPath(config),
222
284
  token: config.token,
285
+ redact,
223
286
  onLog: log,
224
287
  onStatus: status,
225
288
  onManifest() {
@@ -375,6 +438,11 @@ export const serve = async (driver: BridgeDriver) => {
375
438
  onSetupStatus() {
376
439
  return setup.onSetupStatus();
377
440
  },
441
+ onUndelivered(activity) {
442
+ for (const entry of activity) {
443
+ reporter.report(entry);
444
+ }
445
+ },
378
446
  async onSetupSubmit(step, action, args) {
379
447
  return await setup.onSetupSubmit(context, step, action, args);
380
448
  },
@@ -428,8 +496,8 @@ export const serve = async (driver: BridgeDriver) => {
428
496
 
429
497
  log("install complete");
430
498
  } catch (error) {
431
- installError = errorText(error).slice(0, BRIDGE_INSTALL_ERROR_LIMIT);
432
- installErrorText = error instanceof BridgeUserError ? error.text : null;
499
+ installError = redact(errorDetail(error)).slice(0, BRIDGE_INSTALL_ERROR_LIMIT);
500
+ installErrorText = error instanceof BridgeUserError ? redactLocalized(error.text, redact) : null;
433
501
 
434
502
  supervisor.setRuntime(BridgeRuntime.Crashed);
435
503
 
@@ -437,6 +505,14 @@ export const serve = async (driver: BridgeDriver) => {
437
505
  error: installError,
438
506
  });
439
507
 
508
+ context.activity.report({
509
+ name: BridgeEventName.InstallFailed,
510
+ text:
511
+ error instanceof BridgeUserError
512
+ ? error.text
513
+ : INSTALL_FAILED_TEXT[driver.lifecycle.subject ?? BridgeLifecycleSubject.Game],
514
+ });
515
+
440
516
  return;
441
517
  }
442
518
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { steamcmdArgv, steamcmdScript, steamcmdUpdateArgs } from "./steamcmd";
2
+ import { steamcmdArgv, steamcmdFailureText, steamcmdScript, steamcmdUpdateArgs } from "./steamcmd";
3
3
  import { SteamcmdLoginKind, steamcmdLoginArgs } from "./steamcmdLogin";
4
4
  import { SteamcmdPlatform } from "./steamcmdPlatform";
5
5
 
@@ -155,3 +155,34 @@ describe("steamcmdUpdateArgs", () => {
155
155
  ]);
156
156
  });
157
157
  });
158
+
159
+ describe("steamcmdFailureText", () => {
160
+ test("says the download account does not own the app when steam answers no subscription", () => {
161
+ expect(
162
+ steamcmdFailureText(
163
+ {
164
+ stdout: "No subscription",
165
+ stderr: "",
166
+ code: 8,
167
+ },
168
+ "assetto",
169
+ ).en,
170
+ ).toBe("Steam refused to download assetto because the download account does not own it. Contact support.");
171
+ });
172
+
173
+ test("says the download failed for any other exit", () => {
174
+ expect(
175
+ steamcmdFailureText(
176
+ {
177
+ stdout: "",
178
+ stderr: "",
179
+ code: 8,
180
+ },
181
+ "palworld",
182
+ ),
183
+ ).toEqual({
184
+ ar: "ما قدرنا ننزّل palworld من Steam. جرّب مرة ثانية.",
185
+ en: "We could not download palworld from Steam. Try again.",
186
+ });
187
+ });
188
+ });
@@ -1,4 +1,5 @@
1
- import type { BridgeContext } from "../context";
1
+ import type { BridgeContext, BridgeExecResult } from "../context";
2
+ import { BridgeUserError, type LocalizedText } from "../core";
2
3
  import { execDetail } from "../utils";
3
4
  import { installedBuildId } from "./steamApp";
4
5
  import { type SteamcmdLogin, SteamcmdLoginKind, steamcmdLoginArgs } from "./steamcmdLogin";
@@ -25,6 +26,8 @@ const ATTEMPTS = 3;
25
26
 
26
27
  const RETRY_DELAY_MS = 5000;
27
28
 
29
+ const NO_SUBSCRIPTION = "No subscription";
30
+
28
31
  export interface SteamcmdOptions {
29
32
  appId: string;
30
33
  label: string;
@@ -100,6 +103,20 @@ export const steamcmdUpdateArgs = (appId: string, update: SteamcmdUpdateOptions)
100
103
  return args;
101
104
  };
102
105
 
106
+ export const steamcmdFailureText = (result: BridgeExecResult, label: string): LocalizedText => {
107
+ if (`${result.stdout}\n${result.stderr}`.includes(NO_SUBSCRIPTION)) {
108
+ return {
109
+ ar: `Steam رفض تنزيل ${label} لأن حساب التنزيل ما يملكه. راسل الدعم.`,
110
+ en: `Steam refused to download ${label} because the download account does not own it. Contact support.`,
111
+ };
112
+ }
113
+
114
+ return {
115
+ ar: `ما قدرنا ننزّل ${label} من Steam. جرّب مرة ثانية.`,
116
+ en: `We could not download ${label} from Steam. Try again.`,
117
+ };
118
+ };
119
+
103
120
  const installRoot = async (context: BridgeContext) => {
104
121
  const result = await context.exec([
105
122
  "pwd",
@@ -162,7 +179,7 @@ export const createSteamcmd = (context: BridgeContext, options: SteamcmdOptions)
162
179
 
163
180
  const attempts = runOptions.attempts ?? ATTEMPTS;
164
181
 
165
- let failure = "";
182
+ let failure: BridgeExecResult | null = null;
166
183
 
167
184
  for (let attempt = 1; attempt <= attempts; attempt++) {
168
185
  const result = await context.exec(argv, {
@@ -178,7 +195,7 @@ export const createSteamcmd = (context: BridgeContext, options: SteamcmdOptions)
178
195
  return;
179
196
  }
180
197
 
181
- failure = `steamcmd exited with code ${result.code} — ${execDetail(result)}`;
198
+ failure = result;
182
199
 
183
200
  if (attempt < attempts) {
184
201
  context.log("steamcmd did not finish, trying again", {
@@ -191,7 +208,13 @@ export const createSteamcmd = (context: BridgeContext, options: SteamcmdOptions)
191
208
  }
192
209
  }
193
210
 
194
- throw new Error(failure);
211
+ if (!failure) {
212
+ throw new Error("steamcmd was not run");
213
+ }
214
+
215
+ throw new BridgeUserError(steamcmdFailureText(failure, options.label), {
216
+ cause: new Error(`steamcmd exited with code ${failure.code} — ${execDetail(failure)}`),
217
+ });
195
218
  };
196
219
 
197
220
  return {
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { changedSettingKeys } from "./changedSettingKeys";
3
+
4
+ describe("changedSettingKeys", () => {
5
+ test("keeps only the keys whose value really changed", () => {
6
+ expect(
7
+ changedSettingKeys(
8
+ {
9
+ difficulty: "easy",
10
+ expRate: "1.000000",
11
+ pvp: "True",
12
+ name: "My server",
13
+ },
14
+ {
15
+ difficulty: "hard",
16
+ expRate: 1,
17
+ pvp: true,
18
+ name: "My server",
19
+ },
20
+ ),
21
+ ).toEqual([
22
+ "difficulty",
23
+ ]);
24
+ });
25
+
26
+ test("counts a key the stored values do not hold as changed", () => {
27
+ expect(
28
+ changedSettingKeys(
29
+ {},
30
+ {
31
+ seed: "42",
32
+ },
33
+ ),
34
+ ).toEqual([
35
+ "seed",
36
+ ]);
37
+ });
38
+
39
+ test("counts every written key as changed when nothing could be read first", () => {
40
+ const written = {
41
+ difficulty: "hard",
42
+ pvp: false,
43
+ };
44
+
45
+ expect(changedSettingKeys(null, written)).toEqual([
46
+ "difficulty",
47
+ "pvp",
48
+ ]);
49
+ expect(changedSettingKeys(undefined, written)).toEqual([
50
+ "difficulty",
51
+ "pvp",
52
+ ]);
53
+ });
54
+
55
+ test("answers nothing for an empty write", () => {
56
+ expect(
57
+ changedSettingKeys(
58
+ {
59
+ difficulty: "easy",
60
+ },
61
+ {},
62
+ ),
63
+ ).toEqual([]);
64
+ });
65
+ });
@@ -0,0 +1,6 @@
1
+ import type { BridgeValues } from "../core";
2
+ import { sameSettingValue } from "./sameSettingValue";
3
+
4
+ export const changedSettingKeys = (before: BridgeValues | null | undefined, written: BridgeValues): string[] => {
5
+ return Object.keys(written).filter((key) => !before || !sameSettingValue(before[key], written[key]));
6
+ };
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { describeExecFailure } from "./describeExecFailure";
3
+
4
+ describe("describeExecFailure", () => {
5
+ test("names the subject and the exit code in both languages", () => {
6
+ const text = describeExecFailure(
7
+ {
8
+ stdout: "",
9
+ stderr: "java.lang.RuntimeException",
10
+ code: 1,
11
+ },
12
+ "Forge installer",
13
+ );
14
+
15
+ expect(text).toEqual({
16
+ ar: "Forge installer فشل وطلع بكود 1.",
17
+ en: "Forge installer failed with exit code 1.",
18
+ });
19
+ });
20
+
21
+ test("keeps the process output out of the customer text", () => {
22
+ const text = describeExecFailure(
23
+ {
24
+ stdout: "token=abc",
25
+ stderr: "at /home/game/secret",
26
+ code: 2,
27
+ },
28
+ "unzip",
29
+ );
30
+
31
+ expect(JSON.stringify(text)).not.toContain("secret");
32
+ expect(JSON.stringify(text)).not.toContain("token");
33
+ });
34
+ });
@@ -0,0 +1,9 @@
1
+ import type { BridgeExecResult } from "../context";
2
+ import type { LocalizedText } from "../core";
3
+
4
+ export const describeExecFailure = (result: BridgeExecResult, subject: string): LocalizedText => {
5
+ return {
6
+ ar: `${subject} فشل وطلع بكود ${result.code}.`,
7
+ en: `${subject} failed with exit code ${result.code}.`,
8
+ };
9
+ };
@@ -1,4 +1,7 @@
1
+ export * from "./changedSettingKeys";
2
+ export * from "./describeExecFailure";
1
3
  export * from "./execDetail";
2
4
  export * from "./glob";
5
+ export * from "./sameSettingValue";
3
6
  export * from "./stripAnsi";
4
7
  export * from "./token";
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sameSettingValue } from "./sameSettingValue";
3
+
4
+ describe("sameSettingValue", () => {
5
+ test("treats a float the ini codec wrote as equal to the integer the form sends", () => {
6
+ expect(sameSettingValue("1.000000", 1)).toBe(true);
7
+ expect(sameSettingValue("1", "1.000000")).toBe(true);
8
+ });
9
+
10
+ test("treats decimals that differ only in trailing zeros as equal", () => {
11
+ expect(sameSettingValue("0.5", "0.50")).toBe(true);
12
+ });
13
+
14
+ test("treats boolean text as equal whatever its case", () => {
15
+ expect(sameSettingValue("True", "true")).toBe(true);
16
+ expect(sameSettingValue("False", false)).toBe(true);
17
+ expect(sameSettingValue(true, "FALSE")).toBe(false);
18
+ });
19
+
20
+ test("never treats an empty value as zero", () => {
21
+ expect(sameSettingValue("", "0")).toBe(false);
22
+ expect(sameSettingValue(null, 0)).toBe(false);
23
+ });
24
+
25
+ test("keeps a leading-zero string different from its number, since it may be an id", () => {
26
+ expect(sameSettingValue("01", "1")).toBe(false);
27
+ });
28
+
29
+ test("compares the trimmed text of anything else", () => {
30
+ expect(sameSettingValue(" easy ", "easy")).toBe(true);
31
+ expect(sameSettingValue("easy", "hard")).toBe(false);
32
+ expect(sameSettingValue("2", "3")).toBe(false);
33
+ expect(sameSettingValue(undefined, "")).toBe(true);
34
+ });
35
+ });
@@ -0,0 +1,40 @@
1
+ import type { BridgeValue } from "../core";
2
+
3
+ const PLAIN_NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?$/;
4
+
5
+ const BOOLEAN_TEXT: Readonly<Record<string, boolean>> = {
6
+ true: true,
7
+ false: false,
8
+ };
9
+
10
+ const settingText = (value: BridgeValue | undefined) => {
11
+ return value === undefined || value === null ? "" : String(value).trim();
12
+ };
13
+
14
+ const numberOf = (text: string) => {
15
+ return PLAIN_NUMBER.test(text) ? Number(text) : null;
16
+ };
17
+
18
+ const booleanOf = (text: string) => {
19
+ return BOOLEAN_TEXT[text.toLowerCase()] ?? null;
20
+ };
21
+
22
+ export const sameSettingValue = (left: BridgeValue | undefined, right: BridgeValue | undefined): boolean => {
23
+ const a = settingText(left);
24
+ const b = settingText(right);
25
+
26
+ if (a === b) {
27
+ return true;
28
+ }
29
+
30
+ const leftNumber = numberOf(a);
31
+ const rightNumber = numberOf(b);
32
+
33
+ if (leftNumber !== null && rightNumber !== null) {
34
+ return leftNumber === rightNumber;
35
+ }
36
+
37
+ const leftBoolean = booleanOf(a);
38
+
39
+ return leftBoolean !== null && leftBoolean === booleanOf(b);
40
+ };
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BRIDGE_ACTIVITY_DETAILS_LIMIT, BRIDGE_ACTIVITY_LIMIT, BridgeEventName } from "@serverkgg/bridge/protocol";
3
+ import { bridgeActivitiesSchema, bridgeActivitySchema } from "./activity";
4
+
5
+ const entry = {
6
+ name: BridgeEventName.ModpackInstalled,
7
+ text: {
8
+ ar: "ثبّتنا مودباك All the Mods 9",
9
+ en: "Installed modpack All the Mods 9",
10
+ },
11
+ subject: "All the Mods 9",
12
+ details: {
13
+ version: "0.3.2",
14
+ files: 312,
15
+ },
16
+ };
17
+
18
+ describe("bridgeActivitySchema", () => {
19
+ test("accepts a well-formed entry", () => {
20
+ expect(bridgeActivitySchema.parse(entry)).toEqual(entry);
21
+ });
22
+
23
+ test("accepts a name this reader does not know yet, so a newer game still reaches it", () => {
24
+ expect(
25
+ bridgeActivitySchema.safeParse({
26
+ ...entry,
27
+ name: "PortalOpened",
28
+ }).success,
29
+ ).toBe(true);
30
+ });
31
+
32
+ test("refuses a name that is not an event name", () => {
33
+ expect(
34
+ bridgeActivitySchema.safeParse({
35
+ ...entry,
36
+ name: "portal opened",
37
+ }).success,
38
+ ).toBe(false);
39
+ });
40
+
41
+ test("refuses an entry missing one language", () => {
42
+ expect(
43
+ bridgeActivitySchema.safeParse({
44
+ ...entry,
45
+ text: {
46
+ ar: "",
47
+ en: "Installed",
48
+ },
49
+ }).success,
50
+ ).toBe(false);
51
+ });
52
+
53
+ test("refuses more details than an entry carries", () => {
54
+ const details: Record<string, number> = {};
55
+
56
+ for (let index = 0; index <= BRIDGE_ACTIVITY_DETAILS_LIMIT; index++) {
57
+ details[`key${index}`] = index;
58
+ }
59
+
60
+ expect(
61
+ bridgeActivitySchema.safeParse({
62
+ ...entry,
63
+ details,
64
+ }).success,
65
+ ).toBe(false);
66
+ });
67
+
68
+ test("refuses a detail that is not a scalar", () => {
69
+ expect(
70
+ bridgeActivitySchema.safeParse({
71
+ ...entry,
72
+ details: {
73
+ nested: {
74
+ deep: true,
75
+ },
76
+ },
77
+ }).success,
78
+ ).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe("bridgeActivitiesSchema", () => {
83
+ test("refuses more entries than one answer carries", () => {
84
+ expect(
85
+ bridgeActivitiesSchema.safeParse(
86
+ Array.from(
87
+ {
88
+ length: BRIDGE_ACTIVITY_LIMIT + 1,
89
+ },
90
+ () => entry,
91
+ ),
92
+ ).success,
93
+ ).toBe(false);
94
+ });
95
+ });
@@ -0,0 +1,41 @@
1
+ import {
2
+ BRIDGE_ACTIVITY_DETAIL_KEY_LENGTH_LIMIT,
3
+ BRIDGE_ACTIVITY_DETAIL_KEY_PATTERN,
4
+ BRIDGE_ACTIVITY_DETAILS_LIMIT,
5
+ BRIDGE_ACTIVITY_LIMIT,
6
+ BRIDGE_ACTIVITY_NAME_LENGTH_LIMIT,
7
+ BRIDGE_ACTIVITY_NAME_PATTERN,
8
+ BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT,
9
+ BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT,
10
+ } from "@serverkgg/bridge/protocol";
11
+ import { z } from "zod";
12
+
13
+ const activityTextSchema = z.object({
14
+ ar: z.string().min(1).max(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT),
15
+ en: z.string().min(1).max(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT),
16
+ });
17
+
18
+ const activityDetailsSchema = z
19
+ .record(
20
+ z.string().min(1).max(BRIDGE_ACTIVITY_DETAIL_KEY_LENGTH_LIMIT).regex(BRIDGE_ACTIVITY_DETAIL_KEY_PATTERN),
21
+ z.union([
22
+ z.boolean(),
23
+ z.number(),
24
+ z.string().max(BRIDGE_ACTIVITY_TEXT_LENGTH_LIMIT),
25
+ z.null(),
26
+ ]),
27
+ )
28
+ .refine((details) => Object.keys(details).length <= BRIDGE_ACTIVITY_DETAILS_LIMIT);
29
+
30
+ export const bridgeActivitySchema = z.object({
31
+ name: z.string().min(1).max(BRIDGE_ACTIVITY_NAME_LENGTH_LIMIT).regex(BRIDGE_ACTIVITY_NAME_PATTERN),
32
+ text: activityTextSchema,
33
+ subject: z.string().min(1).max(BRIDGE_ACTIVITY_SUBJECT_LENGTH_LIMIT).optional(),
34
+ details: activityDetailsSchema.optional(),
35
+ });
36
+
37
+ export type BridgeActivityInput = z.infer<typeof bridgeActivitySchema>;
38
+
39
+ export const bridgeActivitiesSchema = z.array(bridgeActivitySchema).max(BRIDGE_ACTIVITY_LIMIT);
40
+
41
+ export type BridgeActivitiesInput = z.infer<typeof bridgeActivitiesSchema>;
package/src/wire/index.ts CHANGED
@@ -1,2 +1,3 @@
1
+ export * from "./activity";
1
2
  export * from "./panel";
2
3
  export * from "./zEnvKey";