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