@anweather/game-plugin-ssdk 1.5.1 → 1.6.1

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,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runConformance = runConformance;
4
+ exports.assertConformance = assertConformance;
5
+ const node_util_1 = require("node:util");
6
+ const version_1 = require("./version");
7
+ function participantIds(n) {
8
+ return Array.from({ length: n }, (_, i) => `00000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`);
9
+ }
10
+ function sameMultiset(a, b) {
11
+ return a.length === b.length && [...a].sort().join("\u0000") === [...b].sort().join("\u0000");
12
+ }
13
+ async function runConformance(service, context, options = {}) {
14
+ const report = { passed: [], failed: [] };
15
+ const check = async (name, body) => {
16
+ try {
17
+ await body();
18
+ report.passed.push(name);
19
+ }
20
+ catch (e) {
21
+ report.failed.push({ check: name, reason: e.message });
22
+ }
23
+ };
24
+ const expect = (cond, reason) => {
25
+ if (!cond)
26
+ throw new Error(reason);
27
+ };
28
+ const metadata = await service.GetMetadata({}, context);
29
+ const minPlayers = metadata.minPlayers ?? 0;
30
+ const maxPlayers = metadata.maxPlayers ?? 0;
31
+ await check("GetMetadata: id and seat counts are coherent", async () => {
32
+ expect(typeof metadata.id === "string" && metadata.id.length > 0, "id is empty");
33
+ expect(minPlayers >= 1, `minPlayers ${metadata.minPlayers} < 1`);
34
+ expect(maxPlayers >= minPlayers, `maxPlayers ${metadata.maxPlayers} < minPlayers ${metadata.minPlayers}`);
35
+ });
36
+ await check("Health: ok, same gameType as metadata, current protocol version", async () => {
37
+ const health = await service.Health({}, context);
38
+ expect(health.status === "ok", `status is "${health.status}"`);
39
+ expect(health.gameType === metadata.id, `gameType "${health.gameType}" != metadata id "${metadata.id}"`);
40
+ expect(health.protocolVersion === version_1.PLUGIN_PROTOCOL_VERSION, `protocolVersion ${health.protocolVersion} != ${version_1.PLUGIN_PROTOCOL_VERSION} (the platform would refuse to register this plugin)`);
41
+ });
42
+ const participants = options.participants ?? participantIds(Math.max(1, minPlayers));
43
+ const config = options.config ?? {};
44
+ await check("AssignParticipants: returns the same seats, only reordered", async () => {
45
+ const out = await service.AssignParticipants({ participants, config: config }, context);
46
+ expect(sameMultiset(out.participants ?? [], participants), `got ${JSON.stringify(out.participants)} for ${JSON.stringify(participants)}`);
47
+ });
48
+ const init = await service.InitializeGame({ participants, config: config }, context);
49
+ await check("InitializeGame: yields a state and a first turn among the seats", async () => {
50
+ expect(init.pluginState !== undefined && init.pluginState !== null, "pluginState is missing");
51
+ if (init.nextTurnParticipantId !== undefined) {
52
+ expect(participants.includes(init.nextTurnParticipantId), `nextTurnParticipantId ${init.nextTurnParticipantId} is not a participant`);
53
+ }
54
+ });
55
+ await check("InitializeGame: the state survives a JSON round trip unchanged", async () => {
56
+ const roundTripped = JSON.parse(JSON.stringify(init.pluginState));
57
+ expect((0, node_util_1.isDeepStrictEqual)(roundTripped, init.pluginState), "pluginState changes when serialised (a Map, Set, Date, undefined or class instance inside it?)");
58
+ });
59
+ await check("GetGameStatus: a fresh game is not over", async () => {
60
+ const status = await service.GetGameStatus({ pluginState: init.pluginState }, context);
61
+ expect(status.isGameOver === false, `isGameOver is ${status.isGameOver} right after InitializeGame`);
62
+ });
63
+ await check("GetGameResult: a fresh game has no outcome", async () => {
64
+ const result = await service.GetGameResult({ pluginState: init.pluginState }, context);
65
+ expect(result.outcome === undefined, `outcome "${result.outcome}" right after InitializeGame`);
66
+ });
67
+ await check("ValidateMove: an empty payload is rejected, not thrown", async () => {
68
+ const out = await service.ValidateMove({ pluginState: init.pluginState, move: { participantId: participants[0], payload: {}, sequenceNumber: 1, timestamp: new Date() }, participantId: participants[0] }, context);
69
+ expect(typeof out.valid === "boolean", "valid is not a boolean");
70
+ });
71
+ if (metadata.hasCustomPlayerView) {
72
+ await check("GetPlayerView: every seat gets a view", async () => {
73
+ for (const p of participants) {
74
+ const view = await service.GetPlayerView({ pluginState: init.pluginState, participantId: p }, context);
75
+ expect(view.pluginState !== undefined && view.pluginState !== null, `no view for ${p}`);
76
+ }
77
+ });
78
+ }
79
+ if (metadata.hasBotStrategy) {
80
+ await check("SelectBotMove: the first turn holder gets a move", async () => {
81
+ const mover = init.nextTurnParticipantId ?? participants[0];
82
+ const out = await service.SelectBotMove({ pluginState: init.pluginState, participantId: mover }, context);
83
+ expect(out.payload !== undefined && out.payload !== null, "payload is missing");
84
+ });
85
+ }
86
+ return report;
87
+ }
88
+ async function assertConformance(service, context, options = {}) {
89
+ const report = await runConformance(service, context, options);
90
+ if (report.failed.length > 0) {
91
+ throw new Error(`plugin contract violations:\n` + report.failed.map((f) => ` - ${f.check}: ${f.reason}`).join("\n"));
92
+ }
93
+ return report;
94
+ }
@@ -1,7 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createGamePluginService = exports.mountSsdkHandler = void 0;
3
+ exports.runtimeVersion = exports.PLUGIN_PROTOCOL_VERSION = exports.assertConformance = exports.runConformance = exports.createRegistrationLoop = exports.createPluginApp = exports.servePlugin = exports.createGamePluginService = exports.mountSsdkHandler = void 0;
4
4
  var express_1 = require("./express");
5
5
  Object.defineProperty(exports, "mountSsdkHandler", { enumerable: true, get: function () { return express_1.mountSsdkHandler; } });
6
6
  var defaultService_1 = require("./defaultService");
7
7
  Object.defineProperty(exports, "createGamePluginService", { enumerable: true, get: function () { return defaultService_1.createGamePluginService; } });
8
+ var servePlugin_1 = require("./servePlugin");
9
+ Object.defineProperty(exports, "servePlugin", { enumerable: true, get: function () { return servePlugin_1.servePlugin; } });
10
+ Object.defineProperty(exports, "createPluginApp", { enumerable: true, get: function () { return servePlugin_1.createPluginApp; } });
11
+ var registration_1 = require("./registration");
12
+ Object.defineProperty(exports, "createRegistrationLoop", { enumerable: true, get: function () { return registration_1.createRegistrationLoop; } });
13
+ var conformance_1 = require("./conformance");
14
+ Object.defineProperty(exports, "runConformance", { enumerable: true, get: function () { return conformance_1.runConformance; } });
15
+ Object.defineProperty(exports, "assertConformance", { enumerable: true, get: function () { return conformance_1.assertConformance; } });
16
+ var version_1 = require("./version");
17
+ Object.defineProperty(exports, "PLUGIN_PROTOCOL_VERSION", { enumerable: true, get: function () { return version_1.PLUGIN_PROTOCOL_VERSION; } });
18
+ Object.defineProperty(exports, "runtimeVersion", { enumerable: true, get: function () { return version_1.runtimeVersion; } });
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRegistrationLoop = createRegistrationLoop;
4
+ function createRegistrationLoop(options) {
5
+ const retryDelay = options.retryDelaySeconds ?? 5;
6
+ const doFetch = options.fetchImpl ?? fetch;
7
+ const log = options.log ?? console;
8
+ let timer;
9
+ let stopped = false;
10
+ async function attemptOnce() {
11
+ if (!options.platformUrl)
12
+ return retryDelay;
13
+ try {
14
+ const res = await doFetch(`${options.platformUrl}/internal/plugins/register`, {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify({
18
+ gameType: options.gameType,
19
+ backendBaseUrl: options.backendBaseUrl,
20
+ authToken: options.authToken,
21
+ }),
22
+ });
23
+ if (!res.ok) {
24
+ log.error(`registration rejected: ${res.status} ${await res.text()}`);
25
+ return retryDelay;
26
+ }
27
+ const { leaseSeconds } = (await res.json());
28
+ log.log(`registered with platform-server at ${options.platformUrl} (lease ${leaseSeconds}s)`);
29
+ return Math.max(1, leaseSeconds / 2);
30
+ }
31
+ catch (e) {
32
+ const cause = e.cause;
33
+ log.error(`registration attempt failed: ${e.message}` +
34
+ (cause ? ` (${cause instanceof Error ? cause.message : String(cause)})` : "") +
35
+ ` -- retrying in ${retryDelay}s`);
36
+ return retryDelay;
37
+ }
38
+ }
39
+ async function tick() {
40
+ if (stopped)
41
+ return;
42
+ const delaySeconds = await attemptOnce();
43
+ if (stopped)
44
+ return;
45
+ clearTimeout(timer);
46
+ timer = setTimeout(tick, delaySeconds * 1000);
47
+ timer.unref?.();
48
+ }
49
+ return {
50
+ start() {
51
+ if (!options.platformUrl)
52
+ return;
53
+ stopped = false;
54
+ void tick();
55
+ },
56
+ async stop() {
57
+ stopped = true;
58
+ clearTimeout(timer);
59
+ if (!options.platformUrl)
60
+ return;
61
+ try {
62
+ await doFetch(`${options.platformUrl}/internal/plugins/${options.gameType}`, { method: "DELETE" });
63
+ }
64
+ catch (e) {
65
+ log.error(`deregistration attempt failed: ${e.message}`);
66
+ }
67
+ },
68
+ attemptOnce,
69
+ };
70
+ }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createPluginApp = createPluginApp;
4
+ exports.servePlugin = servePlugin;
5
+ const tslib_1 = require("tslib");
6
+ const express_1 = tslib_1.__importDefault(require("express"));
7
+ const GamePluginService_1 = require("../src/server/GamePluginService");
8
+ const defaultService_1 = require("./defaultService");
9
+ const express_2 = require("./express");
10
+ const registration_1 = require("./registration");
11
+ const version_1 = require("./version");
12
+ const permissiveValidation = () => undefined;
13
+ function createPluginApp(options) {
14
+ const env = options.env ?? process.env;
15
+ const port = Number(env.PORT ?? options.defaultPort ?? 0);
16
+ const authToken = env.PLUGIN_AUTH_TOKEN || undefined;
17
+ const platformUrl = env.PLATFORM_URL || undefined;
18
+ const backendBaseUrl = env.PLUGIN_BACKEND_BASE_URL || `http://localhost:${port}`;
19
+ const log = options.log ?? console;
20
+ const service = (0, defaultService_1.createGamePluginService)({
21
+ Health: async () => ({
22
+ status: "ok",
23
+ gameType: options.gameType,
24
+ protocolVersion: version_1.PLUGIN_PROTOCOL_VERSION,
25
+ runtimeVersion: (0, version_1.runtimeVersion)(),
26
+ }),
27
+ ...options.service,
28
+ });
29
+ const handler = (0, GamePluginService_1.getGamePluginServiceHandler)(service, options.validationCustomizer ?? permissiveValidation);
30
+ const app = (0, express_1.default)();
31
+ app.use(express_1.default.raw({ type: "*/*", limit: options.bodyLimit ?? "16mb" }));
32
+ app.use((req, res, next) => {
33
+ if (req.path === "/v1/health" || req.path.startsWith("/assets/") || req.path === "/assets")
34
+ return next();
35
+ if (!authToken)
36
+ return next();
37
+ if (req.header("X-Plugin-Auth") === `Bearer ${authToken}`)
38
+ return next();
39
+ res.status(401).json({ __type: "Unauthorized", message: "missing or invalid X-Plugin-Auth" });
40
+ });
41
+ if (options.staticDir)
42
+ app.use("/assets", express_1.default.static(options.staticDir));
43
+ options.configure?.(app);
44
+ const makeContext = options.makeContext ?? (() => ({}));
45
+ app.all("/v1/*", (0, express_2.mountSsdkHandler)(handler, makeContext));
46
+ const registration = (0, registration_1.createRegistrationLoop)({
47
+ gameType: options.gameType,
48
+ platformUrl,
49
+ backendBaseUrl,
50
+ authToken,
51
+ retryDelaySeconds: options.retryDelaySeconds,
52
+ fetchImpl: options.fetchImpl,
53
+ log,
54
+ });
55
+ return { app, registration, port, authToken, platformUrl, backendBaseUrl };
56
+ }
57
+ async function servePlugin(options) {
58
+ const built = createPluginApp(options);
59
+ const log = options.log ?? console;
60
+ const server = await new Promise((resolve, reject) => {
61
+ const s = built.app.listen(built.port, () => resolve(s));
62
+ s.once("error", reject);
63
+ });
64
+ const boundPort = server.address().port;
65
+ log.log(`${options.gameType} plugin backend listening on :${boundPort}`);
66
+ built.registration.start();
67
+ let closing;
68
+ const close = () => (closing ??= (async () => {
69
+ await built.registration.stop();
70
+ await new Promise((resolve) => server.close(() => resolve()));
71
+ })());
72
+ if (options.handleSignals ?? true) {
73
+ const onSignal = () => {
74
+ close().finally(() => process.exit(0));
75
+ };
76
+ process.once("SIGTERM", onSignal);
77
+ process.once("SIGINT", onSignal);
78
+ }
79
+ return { ...built, server, port: boundPort, close };
80
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLUGIN_PROTOCOL_VERSION = void 0;
4
+ exports.runtimeVersion = runtimeVersion;
5
+ exports.PLUGIN_PROTOCOL_VERSION = 1;
6
+ function runtimeVersion() {
7
+ try {
8
+ const pkg = require("../../package.json");
9
+ return pkg.version ?? "unknown";
10
+ }
11
+ catch {
12
+ return "unknown";
13
+ }
14
+ }
@@ -623,6 +623,10 @@ var HealthOutput;
623
623
  ]);
624
624
  break;
625
625
  }
626
+ case "runtimeVersion": {
627
+ memberValidators["runtimeVersion"] = new server_common_1.NoOpValidator();
628
+ break;
629
+ }
626
630
  }
627
631
  }
628
632
  return memberValidators[member];
@@ -631,6 +635,7 @@ var HealthOutput;
631
635
  ...getMemberValidator("status").validate(obj.status, `${path}/status`),
632
636
  ...getMemberValidator("gameType").validate(obj.gameType, `${path}/gameType`),
633
637
  ...getMemberValidator("protocolVersion").validate(obj.protocolVersion, `${path}/protocolVersion`),
638
+ ...getMemberValidator("runtimeVersion").validate(obj.runtimeVersion, `${path}/runtimeVersion`),
634
639
  ];
635
640
  };
636
641
  })(HealthOutput || (exports.HealthOutput = HealthOutput = {}));
@@ -733,6 +733,7 @@ const serializeHealthResponse = async (input, ctx) => {
733
733
  body = JSON.stringify((0, smithy_client_1.take)(input, {
734
734
  'gameType': [],
735
735
  'protocolVersion': [],
736
+ 'runtimeVersion': [],
736
737
  'status': [],
737
738
  }));
738
739
  if (body && Object.keys(headers).map((str) => str.toLowerCase()).indexOf('content-length') === -1) {