@anweather/game-plugin-ssdk 1.24.0 → 1.26.0

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.
@@ -1,5 +1,6 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { PLUGIN_PROTOCOL_VERSION } from "./version";
3
+ import { validatePresentation } from "./presentation";
3
4
  import { runLiveConformance } from "./live/conformance";
4
5
  function participantIds(n) {
5
6
  return Array.from({ length: n }, (_, i) => `00000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`);
@@ -30,6 +31,12 @@ export async function runConformance(service, context, options = {}) {
30
31
  expect(minPlayers >= 1, `minPlayers ${metadata.minPlayers} < 1`);
31
32
  expect(maxPlayers >= minPlayers, `maxPlayers ${metadata.maxPlayers} < minPlayers ${metadata.minPlayers}`);
32
33
  });
34
+ if (metadata.presentation !== undefined) {
35
+ await check("GetMetadata: presentation is valid", async () => {
36
+ const problems = validatePresentation(metadata.presentation);
37
+ expect(problems.length === 0, problems.join("; "));
38
+ });
39
+ }
33
40
  await check("Health: ok, same gameType as metadata, current protocol version", async () => {
34
41
  const health = await service.Health({}, context);
35
42
  expect(health.status === "ok", `status is "${health.status}"`);
@@ -3,6 +3,7 @@ export { createGamePluginService } from "./defaultService";
3
3
  export { servePlugin, createPluginApp } from "./servePlugin";
4
4
  export { createRegistrationLoop } from "./registration";
5
5
  export { runConformance, assertConformance } from "./conformance";
6
+ export { validatePresentation, assertPresentation, PRESENTATION_CATEGORIES, MAX_PRESENTATION_BYTES } from "./presentation";
6
7
  export { PLUGIN_PROTOCOL_VERSION, runtimeVersion } from "./version";
7
8
  export { createBlobClient, casUpdate, blobOwner, allBlobOwners, BlobStoreError, isVersionConflict } from "./blobClient";
8
9
  export { attachLive } from "./live/server";
@@ -0,0 +1,93 @@
1
+ export const PRESENTATION_CATEGORIES = ["classic", "strategy", "cards", "word", "wip"];
2
+ export const MAX_PRESENTATION_BYTES = 32 * 1024;
3
+ const str = { kind: "string" };
4
+ const bool = { kind: "boolean" };
5
+ const oneOf = (...values) => ({ kind: "oneOf", values });
6
+ const obj = (fields) => ({ kind: "object", fields });
7
+ const SCHEMA = obj({
8
+ catalogue: obj({
9
+ title: str,
10
+ category: oneOf(...PRESENTATION_CATEGORIES),
11
+ featured: bool,
12
+ poster: obj({ from: { kind: "token" }, to: { kind: "token" }, text: { kind: "token" } }),
13
+ }),
14
+ rules: { kind: "strings" },
15
+ board: obj({
16
+ layout: oneOf("fullscreen", "page"),
17
+ page: obj({ nameplates: oneOf("split", "below", "none"), fullBleed: bool }),
18
+ moveErrors: oneOf("platform", "board"),
19
+ nativeGestures: oneOf("suppress", "allow"),
20
+ recap: { kind: "falseOr", object: obj({ stepMs: { kind: "int", min: 100, max: 10_000 } }) },
21
+ host: obj({ overflowX: oneOf("auto", "hidden", "clip"), width: oneOf("fit", "full") }),
22
+ }),
23
+ rematch: obj({ seatNames: { kind: "strings", size: 2 } }),
24
+ });
25
+ const POSTER_TOKEN = /^([a-z]+-(50|[1-9]00|950)|white|black)$/;
26
+ export function validatePresentation(value) {
27
+ const problems = [];
28
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
29
+ const visit = (v, rule, path) => {
30
+ switch (rule.kind) {
31
+ case "string":
32
+ if (typeof v !== "string")
33
+ problems.push(`${path} is not a string`);
34
+ return;
35
+ case "boolean":
36
+ if (typeof v !== "boolean")
37
+ problems.push(`${path} is not a boolean`);
38
+ return;
39
+ case "oneOf":
40
+ if (typeof v !== "string" || !rule.values.includes(v))
41
+ problems.push(`${path} is not one of ${rule.values.join(", ")}`);
42
+ return;
43
+ case "int":
44
+ if (!Number.isInteger(v) || v < rule.min || v > rule.max) {
45
+ problems.push(`${path} is not an integer in ${rule.min}..${rule.max}`);
46
+ }
47
+ return;
48
+ case "strings":
49
+ if (!Array.isArray(v) || !v.every((s) => typeof s === "string"))
50
+ problems.push(`${path} is not a list of strings`);
51
+ else if (rule.size !== undefined && v.length !== rule.size)
52
+ problems.push(`${path} does not have ${rule.size} entries`);
53
+ return;
54
+ case "token":
55
+ if (typeof v !== "string" || !POSTER_TOKEN.test(v))
56
+ problems.push(`${path} is not a palette token like "amber-700"`);
57
+ return;
58
+ case "falseOr":
59
+ if (v === false)
60
+ return;
61
+ if (!isObject(v))
62
+ problems.push(`${path} is neither false nor an object`);
63
+ else
64
+ visit(v, rule.object, path);
65
+ return;
66
+ case "object":
67
+ if (!isObject(v)) {
68
+ problems.push(`${path} is not an object`);
69
+ return;
70
+ }
71
+ for (const [key, child] of Object.entries(v)) {
72
+ const childRule = rule.fields[key];
73
+ if (!childRule)
74
+ problems.push(`${path}.${key} is not a known field`);
75
+ else if (child !== undefined)
76
+ visit(child, childRule, `${path}.${key}`);
77
+ }
78
+ return;
79
+ }
80
+ };
81
+ visit(value, SCHEMA, "presentation");
82
+ if (problems.length === 0) {
83
+ const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
84
+ if (bytes > MAX_PRESENTATION_BYTES)
85
+ problems.push(`presentation is ${bytes} bytes; the limit is ${MAX_PRESENTATION_BYTES}`);
86
+ }
87
+ return problems;
88
+ }
89
+ export function assertPresentation(value) {
90
+ const problems = validatePresentation(value);
91
+ if (problems.length > 0)
92
+ throw new Error(`invalid presentation:\n ${problems.join("\n ")}`);
93
+ }
@@ -5,8 +5,22 @@ import { mountSsdkHandler } from "./express";
5
5
  import { createRegistrationLoop } from "./registration";
6
6
  import { PLUGIN_PROTOCOL_VERSION, runtimeVersion } from "./version";
7
7
  import { attachLive } from "./live/server";
8
+ import { assertPresentation } from "./presentation";
8
9
  import { bearerMatches } from "./auth";
9
10
  const permissiveValidation = () => undefined;
11
+ function withPresentation(service, presentation) {
12
+ if (presentation === undefined)
13
+ return service;
14
+ return {
15
+ ...service,
16
+ GetMetadata: async (input, context) => {
17
+ const metadata = await service.GetMetadata(input, context);
18
+ return metadata.presentation === undefined
19
+ ? { ...metadata, presentation: presentation }
20
+ : metadata;
21
+ },
22
+ };
23
+ }
10
24
  export function createPluginApp(options) {
11
25
  const env = options.env ?? process.env;
12
26
  const port = Number(env.PORT ?? options.defaultPort ?? 0);
@@ -14,6 +28,9 @@ export function createPluginApp(options) {
14
28
  const platformUrl = env.PLATFORM_URL || undefined;
15
29
  const backendBaseUrl = env.PLUGIN_BACKEND_BASE_URL || `http://localhost:${port}`;
16
30
  const log = options.log ?? console;
31
+ if (options.presentation !== undefined)
32
+ assertPresentation(options.presentation);
33
+ const definition = withPresentation(options.service, options.presentation);
17
34
  const service = createGamePluginService({
18
35
  Health: async () => ({
19
36
  status: "ok",
@@ -21,7 +38,7 @@ export function createPluginApp(options) {
21
38
  protocolVersion: PLUGIN_PROTOCOL_VERSION,
22
39
  runtimeVersion: runtimeVersion(),
23
40
  }),
24
- ...options.service,
41
+ ...definition,
25
42
  });
26
43
  const handler = getGamePluginServiceHandler(service, options.validationCustomizer ?? permissiveValidation);
27
44
  const app = express();
@@ -595,6 +595,10 @@ export var GameTypeMetadata;
595
595
  memberValidators["live"] = new __CompositeStructureValidator(new __NoOpValidator(), LiveCapability.validate);
596
596
  break;
597
597
  }
598
+ case "presentation": {
599
+ memberValidators["presentation"] = new __NoOpValidator();
600
+ break;
601
+ }
598
602
  }
599
603
  }
600
604
  return memberValidators[member];
@@ -617,6 +621,7 @@ export var GameTypeMetadata;
617
621
  ...getMemberValidator("hasSaveFileEditor").validate(obj.hasSaveFileEditor, `${path}/hasSaveFileEditor`),
618
622
  ...getMemberValidator("spaces").validate(obj.spaces, `${path}/spaces`),
619
623
  ...getMemberValidator("live").validate(obj.live, `${path}/live`),
624
+ ...getMemberValidator("presentation").validate(obj.presentation, `${path}/presentation`),
620
625
  ];
621
626
  };
622
627
  })(GameTypeMetadata || (GameTypeMetadata = {}));
@@ -619,6 +619,7 @@ export const serializeGetMetadataResponse = async (input, ctx) => {
619
619
  'live': _ => se_LiveCapability(_, context),
620
620
  'maxPlayers': [],
621
621
  'minPlayers': [],
622
+ 'presentation': _ => se_Document(_, context),
622
623
  'spaces': _ => se_SpaceCapability(_, context),
623
624
  'spectatorSafe': [],
624
625
  'teamPlay': [],