@serverkgg/bridge 0.1.7 → 0.1.9
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/rules/package.md +2 -0
- package/src/bot/botCauses.test.ts +32 -0
- package/src/bot/botCauses.ts +4 -0
- package/src/bot/botDeploy.test.ts +32 -0
- package/src/bot/botDeploy.ts +6 -0
- package/src/bot/detect/corpus/mruniquehacker-knightbot-md.json +35 -0
- package/src/cli/validate.test.ts +429 -3
- package/src/cli/validate.ts +225 -1
- package/src/guides/markdown.test.ts +118 -0
- package/src/guides/markdown.ts +24 -2
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +79 -0
- package/src/manifest/manifest.ts +5 -0
- package/src/manifest/steam.ts +14 -0
- package/src/protocol/bridgeCrash.ts +5 -0
- package/src/protocol/bridgeEvents.ts +1 -0
- package/src/steam/steamcmd.test.ts +367 -1
- package/src/steam/steamcmd.ts +154 -18
package/src/cli/validate.ts
CHANGED
|
@@ -6,8 +6,16 @@ import { z } from "zod";
|
|
|
6
6
|
import { type ArticlePack, compileArticles } from "../blog";
|
|
7
7
|
import { DETECT_TABLE_FILE, renderDetectTable } from "../bot/detect";
|
|
8
8
|
import { BridgeKind, PortProtocol } from "../core";
|
|
9
|
-
import { GuideOpenTab, type GuideOpenTarget, type GuidePack, readGuidePack, readGuidePackage } from "../guides";
|
|
10
9
|
import {
|
|
10
|
+
GuideOpenTab,
|
|
11
|
+
type GuideOpenTarget,
|
|
12
|
+
type GuidePack,
|
|
13
|
+
type ParsedBlock,
|
|
14
|
+
readGuidePack,
|
|
15
|
+
readGuidePackage,
|
|
16
|
+
} from "../guides";
|
|
17
|
+
import {
|
|
18
|
+
compileGlob,
|
|
11
19
|
declaredFiles,
|
|
12
20
|
type GuidePackage,
|
|
13
21
|
isBotManifest,
|
|
@@ -347,6 +355,206 @@ export const confirmReportLines = (driver: BridgeDriver): string[] => {
|
|
|
347
355
|
return lines;
|
|
348
356
|
};
|
|
349
357
|
|
|
358
|
+
const REACH_PROBE = "x";
|
|
359
|
+
|
|
360
|
+
const SERVERK_SEGMENT = ".serverk";
|
|
361
|
+
|
|
362
|
+
const WILDCARD = "*";
|
|
363
|
+
|
|
364
|
+
interface UploadStaging {
|
|
365
|
+
owner: string;
|
|
366
|
+
folder: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
interface FilesOpen {
|
|
370
|
+
owner: string;
|
|
371
|
+
path: string;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const protectedReaches = (path: string, rule: string) => {
|
|
375
|
+
if (rule === path || path.startsWith(`${rule}/`)) {
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!rule.includes(WILDCARD)) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const pattern = compileGlob(rule);
|
|
384
|
+
|
|
385
|
+
return pattern.test(path) || pattern.test(`${path}/${REACH_PROBE}`);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const isServerkPath = (rule: string) => {
|
|
389
|
+
return rule.split("/").some((segment) => segment === SERVERK_SEGMENT || segment.startsWith(`${SERVERK_SEGMENT}-`));
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
export const protectedNameReportLines = (manifest: RunnableManifest): string[] => {
|
|
393
|
+
const lines: string[] = [];
|
|
394
|
+
|
|
395
|
+
for (const rule of manifest.files.protected) {
|
|
396
|
+
if (isServerkPath(rule)) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
lines.push(
|
|
401
|
+
` error: files.protected "${rule}" is not a .serverk path — a package protects only platform state, so drop the rule or move the file under a .serverk name`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return lines;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
export const uploadStagingReportLines = (manifest: RunnableManifest, driver: BridgeDriver): string[] => {
|
|
409
|
+
const stagings: UploadStaging[] = [];
|
|
410
|
+
|
|
411
|
+
for (const tab of driver.panel?.tabs ?? []) {
|
|
412
|
+
for (const section of tab.sections) {
|
|
413
|
+
if (!("upload" in section) || !section.upload) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
stagings.push({
|
|
418
|
+
owner: `tab "${tab.id}" section "${section.id}"`,
|
|
419
|
+
folder: section.upload.staging,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (driver.pending) {
|
|
425
|
+
stagings.push({
|
|
426
|
+
owner: "the pending upload",
|
|
427
|
+
folder: driver.pending.staging,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const lines: string[] = [];
|
|
432
|
+
|
|
433
|
+
for (const staging of stagings) {
|
|
434
|
+
for (const rule of manifest.files.protected) {
|
|
435
|
+
if (!protectedReaches(staging.folder, rule)) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
lines.push(
|
|
440
|
+
` error: upload staging "${staging.folder}" of ${staging.owner} is protected by "${rule}" — a protected path refuses the customer's upload, so take the folder off \`files.protected\``,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return lines;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const literalPrefixOf = (match: string) => {
|
|
449
|
+
const segments = match.split("/");
|
|
450
|
+
const wild = segments.findIndex((segment) => segment.includes(WILDCARD));
|
|
451
|
+
|
|
452
|
+
return (wild === -1 ? segments : segments.slice(0, wild)).join("/");
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
export const notableReportLines = (manifest: RunnableManifest): string[] => {
|
|
456
|
+
const lines: string[] = [];
|
|
457
|
+
|
|
458
|
+
for (const note of manifest.files.notable) {
|
|
459
|
+
const prefix = literalPrefixOf(note.match);
|
|
460
|
+
|
|
461
|
+
if (prefix === "") {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
for (const rule of manifest.files.protected) {
|
|
466
|
+
if (!protectedReaches(prefix, rule)) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
lines.push(
|
|
471
|
+
` error: files.notable "${note.match}" is protected by "${rule}" — the file manager hides a protected path before it annotates it, so the note never shows; take one of the two off`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return lines;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const guideOpenTargetsOf = (blocks: ParsedBlock[]) => {
|
|
480
|
+
const leaves = blocks.flatMap((block) =>
|
|
481
|
+
block.type === "conditional"
|
|
482
|
+
? [
|
|
483
|
+
...block.when,
|
|
484
|
+
...block.otherwise,
|
|
485
|
+
]
|
|
486
|
+
: [
|
|
487
|
+
block,
|
|
488
|
+
],
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
return leaves.flatMap((block) =>
|
|
492
|
+
block.type === "open"
|
|
493
|
+
? [
|
|
494
|
+
block.target,
|
|
495
|
+
]
|
|
496
|
+
: [],
|
|
497
|
+
);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const filesPathOf = (target: GuideOpenTarget) => {
|
|
501
|
+
return target.tab === GuideOpenTab.Files ? target.path : null;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
export const openTargetReportLines = (
|
|
505
|
+
manifest: RunnableManifest,
|
|
506
|
+
driver: BridgeDriver,
|
|
507
|
+
pack: GuidePack | null,
|
|
508
|
+
): string[] => {
|
|
509
|
+
const opens: FilesOpen[] = [];
|
|
510
|
+
|
|
511
|
+
const collect = (owner: string, targets: GuideOpenTarget[]) => {
|
|
512
|
+
for (const target of targets) {
|
|
513
|
+
const path = filesPathOf(target);
|
|
514
|
+
|
|
515
|
+
if (path === null) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
opens.push({
|
|
520
|
+
owner,
|
|
521
|
+
path,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
for (const draft of pack?.drafts ?? []) {
|
|
527
|
+
collect(`guide "${draft.id}" (ar)`, guideOpenTargetsOf(draft.body.ar));
|
|
528
|
+
collect(`guide "${draft.id}" (en)`, guideOpenTargetsOf(draft.body.en));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
for (const step of driver.setup?.steps ?? []) {
|
|
532
|
+
if (step.kind !== BridgeSetupStepKind.Open) {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
collect(`setup step "${step.id}"`, [
|
|
537
|
+
step.target,
|
|
538
|
+
]);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const lines: string[] = [];
|
|
542
|
+
|
|
543
|
+
for (const open of opens) {
|
|
544
|
+
for (const rule of manifest.files.protected) {
|
|
545
|
+
if (!protectedReaches(open.path, rule)) {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
lines.push(
|
|
550
|
+
` error: ${open.owner} opens files at "${open.path}", which "${rule}" protects — the panel refuses a protected path, so the link is dead; point it elsewhere or take the rule off \`files.protected\``,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return lines;
|
|
556
|
+
};
|
|
557
|
+
|
|
350
558
|
const openTargetOf = (target: GuideOpenTarget) => {
|
|
351
559
|
if (target.tab === GuideOpenTab.Panel) {
|
|
352
560
|
return `panel tab "${target.tabId}"`;
|
|
@@ -451,6 +659,12 @@ const validateRunnablePackage = async (
|
|
|
451
659
|
}
|
|
452
660
|
|
|
453
661
|
const pack = readGuidePack(source, manifest);
|
|
662
|
+
const nameLines = protectedNameReportLines(manifest);
|
|
663
|
+
|
|
664
|
+
if (nameLines.length > 0) {
|
|
665
|
+
throw new Error(`files.protected names a path that is not platform state:\n${nameLines.join("\n")}`);
|
|
666
|
+
}
|
|
667
|
+
|
|
454
668
|
const detectLines = botDetectLines(source, manifest);
|
|
455
669
|
const driverPath = driverArg ? resolve(driverArg) : join(dir, "src/driver.ts");
|
|
456
670
|
|
|
@@ -476,6 +690,16 @@ const validateRunnablePackage = async (
|
|
|
476
690
|
);
|
|
477
691
|
}
|
|
478
692
|
|
|
693
|
+
const reachLines = [
|
|
694
|
+
...uploadStagingReportLines(manifest, driver),
|
|
695
|
+
...notableReportLines(manifest),
|
|
696
|
+
...openTargetReportLines(manifest, driver, pack),
|
|
697
|
+
];
|
|
698
|
+
|
|
699
|
+
if (reachLines.length > 0) {
|
|
700
|
+
throw new Error(`a protected path is where the customer is sent:\n${reachLines.join("\n")}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
479
703
|
const conditionalPorts = manifest.container.ports.filter((port) => port.activeWhen.length > 0).length;
|
|
480
704
|
const pairedPorts = manifest.container.ports.filter((port) => port.protocol === PortProtocol.TcpUdp).length;
|
|
481
705
|
const blockPorts = manifest.container.ports.filter((port) => port.count > 1).length;
|
|
@@ -390,3 +390,121 @@ describe("conditionals", () => {
|
|
|
390
390
|
expect(() => parse(":::if server.address")).toThrow(/conditionals open with :::when/);
|
|
391
391
|
});
|
|
392
392
|
});
|
|
393
|
+
|
|
394
|
+
describe("heading anchors", () => {
|
|
395
|
+
it("takes the id after a heading and keeps it out of the heading text", () => {
|
|
396
|
+
const { blocks } = parse(
|
|
397
|
+
[
|
|
398
|
+
"## The token is invalid {#token-invalid}",
|
|
399
|
+
"",
|
|
400
|
+
"### التوكن غير صحيح {#token-invalid-2}",
|
|
401
|
+
].join("\n"),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
expect(blocks).toEqual([
|
|
405
|
+
{
|
|
406
|
+
type: "heading",
|
|
407
|
+
level: 2,
|
|
408
|
+
id: "token-invalid",
|
|
409
|
+
children: [
|
|
410
|
+
{
|
|
411
|
+
type: "text",
|
|
412
|
+
text: "The token is invalid",
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
type: "heading",
|
|
418
|
+
level: 3,
|
|
419
|
+
id: "token-invalid-2",
|
|
420
|
+
children: [
|
|
421
|
+
{
|
|
422
|
+
type: "text",
|
|
423
|
+
text: "التوكن غير صحيح",
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
},
|
|
427
|
+
]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("keeps inline markup before the anchor", () => {
|
|
431
|
+
const { blocks } = parse("## `ffmpeg` is missing {#ffmpeg-missing}");
|
|
432
|
+
|
|
433
|
+
expect(blocks[0]).toEqual({
|
|
434
|
+
type: "heading",
|
|
435
|
+
level: 2,
|
|
436
|
+
id: "ffmpeg-missing",
|
|
437
|
+
children: [
|
|
438
|
+
{
|
|
439
|
+
type: "code",
|
|
440
|
+
text: "ffmpeg",
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
type: "text",
|
|
444
|
+
text: " is missing",
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("still slugs a heading without an anchor", () => {
|
|
451
|
+
const { blocks } = parse("## Find your line");
|
|
452
|
+
|
|
453
|
+
expect(blocks[0]).toMatchObject({
|
|
454
|
+
id: "find-your-line",
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("moves a later slug aside instead of taking an anchored id", () => {
|
|
459
|
+
const { blocks } = parse(
|
|
460
|
+
[
|
|
461
|
+
"## Rate limits {#rate-limited}",
|
|
462
|
+
"",
|
|
463
|
+
"## Rate limited",
|
|
464
|
+
].join("\n"),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect(blocks.map((block) => (block.type === "heading" ? block.id : null))).toEqual([
|
|
468
|
+
"rate-limited",
|
|
469
|
+
"rate-limited-2",
|
|
470
|
+
]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("rejects an anchored id that is already used", () => {
|
|
474
|
+
expect(() =>
|
|
475
|
+
parse(
|
|
476
|
+
[
|
|
477
|
+
"## Rate limited",
|
|
478
|
+
"",
|
|
479
|
+
"## Too many requests {#rate-limited}",
|
|
480
|
+
].join("\n"),
|
|
481
|
+
),
|
|
482
|
+
).toThrow(/heading id "rate-limited" is already used/);
|
|
483
|
+
expect(() =>
|
|
484
|
+
parse(
|
|
485
|
+
[
|
|
486
|
+
"## One {#same}",
|
|
487
|
+
"",
|
|
488
|
+
"## Two {#same}",
|
|
489
|
+
].join("\n"),
|
|
490
|
+
),
|
|
491
|
+
).toThrow(/heading id "same" is already used/);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("rejects an id that is not lowercase letters, digits and hyphens", () => {
|
|
495
|
+
for (const id of [
|
|
496
|
+
"Token-Invalid",
|
|
497
|
+
"token_invalid",
|
|
498
|
+
"token invalid",
|
|
499
|
+
"-token",
|
|
500
|
+
"token--invalid",
|
|
501
|
+
"",
|
|
502
|
+
]) {
|
|
503
|
+
expect(() => parse(`## The token is invalid {#${id}}`)).toThrow(/must be lowercase letters, digits/);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("rejects an anchor with no heading text", () => {
|
|
508
|
+
expect(() => parse("## {#token-invalid}")).toThrow(/empty text run/);
|
|
509
|
+
});
|
|
510
|
+
});
|
package/src/guides/markdown.ts
CHANGED
|
@@ -52,6 +52,10 @@ const HEADING_2_PATTERN = /^##\s+(.+)$/;
|
|
|
52
52
|
|
|
53
53
|
const HEADING_3_PATTERN = /^###\s+(.+)$/;
|
|
54
54
|
|
|
55
|
+
const HEADING_ANCHOR_PATTERN = /^(.*?)\s*\{#([^}]*)\}$/;
|
|
56
|
+
|
|
57
|
+
const HEADING_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
58
|
+
|
|
55
59
|
const CALLOUT_OPEN_PATTERN = /^>\s*\[!(note|warning|danger)\]\s*(.*)$/;
|
|
56
60
|
|
|
57
61
|
const IMAGE_LINE_PATTERN = /^!\[([^\]]*)\]\(([^)\s]+)\)$/;
|
|
@@ -285,6 +289,20 @@ const headingId = (inlines: GuideInline[], used: Map<string, number>, fallback:
|
|
|
285
289
|
return count === 0 ? base : `${base}-${count + 1}`;
|
|
286
290
|
};
|
|
287
291
|
|
|
292
|
+
const anchoredHeadingId = (path: string, line: number, id: string, used: Map<string, number>) => {
|
|
293
|
+
if (!HEADING_ID_PATTERN.test(id)) {
|
|
294
|
+
fail(path, line, `heading id "${id}" must be lowercase letters, digits and single hyphens`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (used.has(id)) {
|
|
298
|
+
fail(path, line, `heading id "${id}" is already used in this guide`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
used.set(id, 1);
|
|
302
|
+
|
|
303
|
+
return id;
|
|
304
|
+
};
|
|
305
|
+
|
|
288
306
|
const parseOpenTarget = (path: string, line: number, value: string): GuideOpenTarget => {
|
|
289
307
|
const separator = value.indexOf(":");
|
|
290
308
|
const tab = separator === -1 ? value : value.slice(0, separator);
|
|
@@ -459,12 +477,16 @@ const scanLeafBlocks = (scanner: Scanner, nested: boolean): ParsedLeafBlock[] =>
|
|
|
459
477
|
fail(path, lineNumber, "unsupported heading depth; use ## or ###");
|
|
460
478
|
}
|
|
461
479
|
|
|
462
|
-
const
|
|
480
|
+
const text = match[1].trim();
|
|
481
|
+
const anchor = text.match(HEADING_ANCHOR_PATTERN);
|
|
482
|
+
const children = parseInlines(path, lineNumber, anchor ? (anchor[1] ?? "") : text);
|
|
463
483
|
|
|
464
484
|
blocks.push({
|
|
465
485
|
type: "heading",
|
|
466
486
|
level: h3 ? 3 : 2,
|
|
467
|
-
id:
|
|
487
|
+
id: anchor
|
|
488
|
+
? anchoredHeadingId(path, lineNumber, anchor[2] ?? "", scanner.headingIds)
|
|
489
|
+
: headingId(children, scanner.headingIds, `section-${scanner.headingIds.size + 1}`),
|
|
468
490
|
children,
|
|
469
491
|
});
|
|
470
492
|
|
package/src/manifest/index.ts
CHANGED
|
@@ -931,6 +931,85 @@ describe("presence", () => {
|
|
|
931
931
|
});
|
|
932
932
|
});
|
|
933
933
|
|
|
934
|
+
describe("steam", () => {
|
|
935
|
+
it("is optional, so a game that declares none parses", () => {
|
|
936
|
+
const result = gameManifestSchema.safeParse(base());
|
|
937
|
+
|
|
938
|
+
expect(result.success).toBe(true);
|
|
939
|
+
expect(result.success && result.data.steam).toBeUndefined();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("accepts the server app id", () => {
|
|
943
|
+
const result = gameManifestSchema.safeParse({
|
|
944
|
+
...base(),
|
|
945
|
+
steam: {
|
|
946
|
+
appId: 896_660,
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
expect(result.success).toBe(true);
|
|
951
|
+
expect(result.success && result.data.steam).toEqual({
|
|
952
|
+
appId: 896_660,
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("accepts the variable that holds the Steam branch", () => {
|
|
957
|
+
const result = gameManifestSchema.safeParse({
|
|
958
|
+
...base(),
|
|
959
|
+
steam: {
|
|
960
|
+
appId: 896_660,
|
|
961
|
+
branchVariable: "VALHEIM_BRANCH",
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
expect(result.success).toBe(true);
|
|
966
|
+
expect(result.success && result.data.steam).toEqual({
|
|
967
|
+
appId: 896_660,
|
|
968
|
+
branchVariable: "VALHEIM_BRANCH",
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("refuses a branch variable that is not an environment key", () => {
|
|
973
|
+
const result = gameManifestSchema.safeParse({
|
|
974
|
+
...base(),
|
|
975
|
+
steam: {
|
|
976
|
+
appId: 896_660,
|
|
977
|
+
branchVariable: "valheim-branch",
|
|
978
|
+
},
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
expect(result.success).toBe(false);
|
|
982
|
+
expect(result.error && z.prettifyError(result.error)).toContain("must be SCREAMING_SNAKE_CASE");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
for (const appId of [
|
|
986
|
+
0,
|
|
987
|
+
-1,
|
|
988
|
+
2.5,
|
|
989
|
+
"258550",
|
|
990
|
+
]) {
|
|
991
|
+
it(`refuses ${JSON.stringify(appId)} as an app id`, () => {
|
|
992
|
+
const result = gameManifestSchema.safeParse({
|
|
993
|
+
...base(),
|
|
994
|
+
steam: {
|
|
995
|
+
appId,
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
expect(result.success).toBe(false);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
it("refuses a block with no app id", () => {
|
|
1004
|
+
const result = gameManifestSchema.safeParse({
|
|
1005
|
+
...base(),
|
|
1006
|
+
steam: {},
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
expect(result.success).toBe(false);
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
|
|
934
1013
|
describe("container runtime", () => {
|
|
935
1014
|
it("materializes the defaults when the manifest declares no runtime", () => {
|
|
936
1015
|
const manifest = manifestSchema.parse(base());
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
zSlug,
|
|
23
23
|
} from "./primitives";
|
|
24
24
|
import { ManifestPlatform, manifestRuntimeSchema, WINE_MIN_SHM_MB } from "./runtime";
|
|
25
|
+
import { manifestSteamSchema } from "./steam";
|
|
25
26
|
|
|
26
27
|
export const MANIFEST_SCHEMA_VERSION = "serverk.gg/v1";
|
|
27
28
|
|
|
@@ -825,6 +826,10 @@ export const gameManifestSchema = z
|
|
|
825
826
|
secrets: z.array(manifestSecretSchema).max(16).default([]),
|
|
826
827
|
files: manifestFilesSchema.prefault({}),
|
|
827
828
|
presence: manifestPresenceSchema.optional(),
|
|
829
|
+
steam: manifestSteamSchema.optional().meta({
|
|
830
|
+
description:
|
|
831
|
+
"The Steam app behind this game. Declare it when steamcmd installs the server, so the platform sees when a newer game version is out.",
|
|
832
|
+
}),
|
|
828
833
|
})
|
|
829
834
|
.check((ctx) => {
|
|
830
835
|
if (ctx.issues.length > 0) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { zEnvKey } from "./primitives";
|
|
3
|
+
|
|
4
|
+
export const manifestSteamSchema = z.object({
|
|
5
|
+
appId: z.int().positive().meta({
|
|
6
|
+
description: "Steam app id of the dedicated server, the one steamcmd installs with app_update.",
|
|
7
|
+
}),
|
|
8
|
+
branchVariable: zEnvKey.optional().meta({
|
|
9
|
+
description:
|
|
10
|
+
"The server variable that holds the Steam branch the customer picked. Omit it when the package installs the public branch only.",
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type ManifestSteam = z.infer<typeof manifestSteamSchema>;
|
|
@@ -17,6 +17,7 @@ export enum BridgeCrashCause {
|
|
|
17
17
|
FfmpegMissing = "ffmpeg-missing",
|
|
18
18
|
AudioopRemoved = "audioop-removed",
|
|
19
19
|
RateLimited = "rate-limited",
|
|
20
|
+
VariableMissing = "variable-missing",
|
|
20
21
|
Unknown = "unknown",
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -75,6 +76,10 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
|
|
|
75
76
|
name: BridgeEventName.FfmpegMissing,
|
|
76
77
|
cause: BridgeCrashCause.FfmpegMissing,
|
|
77
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: BridgeEventName.VariableMissing,
|
|
81
|
+
cause: BridgeCrashCause.VariableMissing,
|
|
82
|
+
},
|
|
78
83
|
{
|
|
79
84
|
name: BridgeEventName.IntentsDisallowed,
|
|
80
85
|
cause: BridgeCrashCause.IntentsDisallowed,
|
|
@@ -47,6 +47,7 @@ export enum BridgeEventName {
|
|
|
47
47
|
TickLagging = "TickLagging",
|
|
48
48
|
TokenInUse = "TokenInUse",
|
|
49
49
|
TokenInvalid = "TokenInvalid",
|
|
50
|
+
VariableMissing = "VariableMissing",
|
|
50
51
|
WorldActivated = "WorldActivated",
|
|
51
52
|
WorldCloned = "WorldCloned",
|
|
52
53
|
WorldCorrupt = "WorldCorrupt",
|