@serverkgg/bridge 0.0.14 → 0.0.16

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,107 @@
1
+ import type { BridgeContext } from "../context";
2
+ import { playerSummaries, steamWebApiReady } from "./webApi";
3
+
4
+ export const STEAM_AVATAR_CACHE_TTL_MS = 21_600_000;
5
+
6
+ const STEAM_AVATAR = /\/([0-9a-f]{40})(?:_[a-z]+)?\.jpg/;
7
+
8
+ export const steamAvatarHashOf = (url: string): string | null => {
9
+ return url.match(STEAM_AVATAR)?.at(1) ?? null;
10
+ };
11
+
12
+ export interface SteamAvatars {
13
+ resolve(context: BridgeContext, ids: string[]): Promise<Map<string, string>>;
14
+ }
15
+
16
+ export interface SteamAvatarsOptions {
17
+ steamIdOf: (id: string) => string | null;
18
+ }
19
+
20
+ interface CachedAvatar {
21
+ hash: string;
22
+ storedAt: number;
23
+ }
24
+
25
+ export const createSteamAvatars = (options: SteamAvatarsOptions): SteamAvatars => {
26
+ const { steamIdOf } = options;
27
+ const cached = new Map<string, CachedAvatar>();
28
+
29
+ let warned = false;
30
+
31
+ const hashOf = (steamId: string, now: number) => {
32
+ const entry = cached.get(steamId);
33
+
34
+ if (!entry) {
35
+ return null;
36
+ }
37
+
38
+ if (now - entry.storedAt >= STEAM_AVATAR_CACHE_TTL_MS) {
39
+ cached.delete(steamId);
40
+
41
+ return null;
42
+ }
43
+
44
+ return entry.hash;
45
+ };
46
+
47
+ const known = (ids: string[], now: number) => {
48
+ const hashes = new Map<string, string>();
49
+
50
+ for (const id of ids) {
51
+ const steamId = steamIdOf(id);
52
+ const hash = steamId === null ? null : hashOf(steamId, now);
53
+
54
+ if (hash !== null) {
55
+ hashes.set(id, hash);
56
+ }
57
+ }
58
+
59
+ return hashes;
60
+ };
61
+
62
+ return {
63
+ async resolve(context, ids) {
64
+ const now = Date.now();
65
+ const missing = new Set(
66
+ ids.flatMap((id) => {
67
+ const steamId = steamIdOf(id);
68
+
69
+ return steamId === null || hashOf(steamId, now) !== null
70
+ ? []
71
+ : [
72
+ steamId,
73
+ ];
74
+ }),
75
+ );
76
+
77
+ if (missing.size === 0 || !steamWebApiReady(context)) {
78
+ return known(ids, now);
79
+ }
80
+
81
+ try {
82
+ for (const summary of await playerSummaries(context, [
83
+ ...missing,
84
+ ])) {
85
+ const hash = steamAvatarHashOf(summary.avatarfull);
86
+
87
+ if (hash !== null) {
88
+ cached.set(summary.steamid, {
89
+ hash,
90
+ storedAt: now,
91
+ });
92
+ }
93
+ }
94
+ } catch (error) {
95
+ if (!warned) {
96
+ warned = true;
97
+
98
+ context.log.warn("the steam web api did not answer for the roster", {
99
+ error: error instanceof Error ? error.message : String(error),
100
+ });
101
+ }
102
+ }
103
+
104
+ return known(ids, now);
105
+ },
106
+ };
107
+ };
@@ -1,3 +1,4 @@
1
+ export * from "./avatars";
1
2
  export * from "./steamApp";
2
3
  export * from "./steamcmd";
3
4
  export * from "./steamcmdLogin";
@@ -10,6 +10,7 @@ import {
10
10
  BridgeKind,
11
11
  BridgeLayout,
12
12
  type BridgeManifest,
13
+ BridgePlace,
13
14
  type BridgeSetupManifest,
14
15
  BridgeSetupStepKind,
15
16
  BridgeUploadMode,
@@ -604,3 +605,81 @@ describe("the setup mirror", () => {
604
605
  expect(bridgeManifestSchema.safeParse(source).success).toBe(false);
605
606
  });
606
607
  });
608
+
609
+ const placedManifest = (): BridgeManifest => ({
610
+ version: "1.0.0",
611
+ panel: {
612
+ tabs: [
613
+ {
614
+ id: "roster",
615
+ title: text("Roster"),
616
+ sections: [
617
+ {
618
+ layout: BridgeLayout.Table,
619
+ id: "online",
620
+ module: "players",
621
+ place: BridgePlace.Players,
622
+ columns: [
623
+ {
624
+ key: "name",
625
+ label: text("Name"),
626
+ },
627
+ ],
628
+ actions: [
629
+ {
630
+ id: "ban",
631
+ label: text("Ban"),
632
+ confirm: BridgeConfirm.Strong,
633
+ offline: true,
634
+ },
635
+ ],
636
+ },
637
+ ],
638
+ },
639
+ {
640
+ id: "settings",
641
+ title: text("Settings"),
642
+ sections: [
643
+ {
644
+ layout: BridgeLayout.Form,
645
+ id: "world",
646
+ title: text("World"),
647
+ help: text("These settings apply the next time the server starts"),
648
+ target: BridgeFormTarget.Variables,
649
+ fields: [
650
+ {
651
+ key: "WORLD_NAME",
652
+ control: BridgeControl.Text,
653
+ label: text("World name"),
654
+ },
655
+ ],
656
+ },
657
+ ],
658
+ },
659
+ ],
660
+ },
661
+ terminal: null,
662
+ setup: null,
663
+ modules: [
664
+ {
665
+ id: "players",
666
+ kind: BridgeKind.Collection,
667
+ requiresRunning: true,
668
+ refreshSeconds: 20,
669
+ pageSize: null,
670
+ dependsOn: [],
671
+ actions: [
672
+ "ban",
673
+ ],
674
+ },
675
+ ],
676
+ events: [],
677
+ });
678
+
679
+ describe("the placement mirror", () => {
680
+ test("carries place, an offline row action and a form help through unchanged", () => {
681
+ const source = placedManifest();
682
+
683
+ expect(bridgeManifestSchema.parse(source)).toEqual(source);
684
+ });
685
+ });
package/src/wire/panel.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  BridgeLayout,
13
13
  type BridgeManifest,
14
14
  type BridgePanel,
15
+ BridgePlace,
15
16
  type BridgeSection,
16
17
  type BridgeSetupManifest,
17
18
  type BridgeSetupStep,
@@ -118,6 +119,7 @@ export const bridgeSectionActionSchema = z.object({
118
119
  confirm: z.enum(BridgeConfirm).optional(),
119
120
  confirmText: panelTextSchema.optional(),
120
121
  download: z.boolean().optional(),
122
+ offline: z.boolean().optional(),
121
123
  fields: z.array(bridgeFieldSchema).max(ACTION_FIELDS_LIMIT).optional(),
122
124
  });
123
125
 
@@ -148,6 +150,7 @@ export const bridgeActionsSectionSchema = z.object({
148
150
  id: zBridgeId,
149
151
  title: panelTextSchema.optional(),
150
152
  help: panelTextSchema.optional(),
153
+ place: z.enum(BridgePlace).optional(),
151
154
  module: zBridgeModuleId,
152
155
  actions: z.array(bridgeSectionActionSchema).min(1).max(SECTION_ACTIONS_LIMIT),
153
156
  visibleWhen: bridgeVisibleWhenSchema.optional(),
@@ -158,6 +161,7 @@ export const bridgeFormSectionSchema = z.object({
158
161
  layout: z.literal(BridgeLayout.Form),
159
162
  id: zBridgeId,
160
163
  title: panelTextSchema.optional(),
164
+ help: panelTextSchema.optional(),
161
165
  target: z.enum(BridgeFormTarget),
162
166
  module: zBridgeModuleId.optional(),
163
167
  preview: bridgePreviewRefSchema.optional(),
@@ -175,6 +179,8 @@ export const bridgeDetailSectionSchema = z.object({
175
179
  layout: z.literal(BridgeLayout.Detail),
176
180
  id: zBridgeId,
177
181
  title: panelTextSchema.optional(),
182
+ help: panelTextSchema.optional(),
183
+ place: z.enum(BridgePlace).optional(),
178
184
  module: zBridgeModuleId,
179
185
  actions: z.array(bridgeSectionActionSchema).max(SECTION_ACTIONS_LIMIT).optional(),
180
186
  reinstall: z.boolean().optional(),
@@ -196,6 +202,8 @@ export const bridgeTableSectionSchema = z.object({
196
202
  layout: z.literal(BridgeLayout.Table),
197
203
  id: zBridgeId,
198
204
  title: panelTextSchema.optional(),
205
+ help: panelTextSchema.optional(),
206
+ place: z.enum(BridgePlace).optional(),
199
207
  module: zBridgeModuleId,
200
208
  restartHint: z.boolean().optional(),
201
209
  columns: z.array(bridgeColumnSchema).min(1).max(8),
@@ -211,6 +219,8 @@ export const bridgeCardsSectionSchema = z.object({
211
219
  layout: z.literal(BridgeLayout.Cards),
212
220
  id: zBridgeId,
213
221
  title: panelTextSchema.optional(),
222
+ help: panelTextSchema.optional(),
223
+ place: z.enum(BridgePlace).optional(),
214
224
  module: zBridgeModuleId,
215
225
  titleKey: zKey,
216
226
  subtitleKey: zKey.optional(),
@@ -224,6 +234,7 @@ export const bridgeCatalogSectionSchema = z.object({
224
234
  layout: z.literal(BridgeLayout.Catalog),
225
235
  id: zBridgeId,
226
236
  title: panelTextSchema.optional(),
237
+ help: panelTextSchema.optional(),
227
238
  module: zBridgeModuleId,
228
239
  restartHint: z.boolean().optional(),
229
240
  reinstall: z.boolean().optional(),