@anweather/game-plugin-ssdk 1.17.0 → 1.19.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.
@@ -2,6 +2,7 @@ export const blobOwner = {
2
2
  save: (saveFileId) => `save:${saveFileId}`,
3
3
  player: (participantId) => `player:${participantId}`,
4
4
  world: (worldId) => `world:${worldId}`,
5
+ space: (spaceId) => `space:${spaceId}`,
5
6
  };
6
7
  export class BlobStoreError extends Error {
7
8
  type;
@@ -24,7 +25,7 @@ const ERROR_TYPES = new Set([
24
25
  export function createBlobClient(options) {
25
26
  const doFetch = options.fetchImpl ?? fetch;
26
27
  const timeoutMs = options.timeoutMs ?? 5000;
27
- const root = `${options.platformUrl}/internal/plugins/${encodeURIComponent(options.gameType)}/blobs`;
28
+ const root = `${options.platformUrl}/internal/plugins/${encodeURIComponent(options.gameType)}`;
28
29
  async function call(method, path, body) {
29
30
  const headers = {};
30
31
  if (options.authToken)
@@ -46,7 +47,7 @@ export function createBlobClient(options) {
46
47
  throw new BlobStoreError(type, res.status, parsed);
47
48
  throw new Error(`blob store ${method} ${path}: ${res.status} ${text.slice(0, 200)}`);
48
49
  }
49
- const at = (owner, key) => key === undefined ? encodeURIComponent(owner) : `${encodeURIComponent(owner)}/${encodeURIComponent(key)}`;
50
+ const at = (owner, key) => key === undefined ? `blobs/${encodeURIComponent(owner)}` : `blobs/${encodeURIComponent(owner)}/${encodeURIComponent(key)}`;
50
51
  return {
51
52
  get: (owner, key) => call("GET", at(owner, key)),
52
53
  put: (owner, key, value, expectedVersion) => call("PUT", at(owner, key), { value, expectedVersion }),
@@ -54,8 +55,24 @@ export function createBlobClient(options) {
54
55
  batchGet: async (owner, keys) => (await call("POST", `${at(owner)}/batch-get`, { keys })).items,
55
56
  batchPut: async (owner, writes) => (await call("POST", `${at(owner)}/batch-put`, { writes })).items,
56
57
  list: (owner) => call("GET", at(owner)),
58
+ listOwners: (prefix = "", page = {}) => {
59
+ const query = new URLSearchParams({ prefix });
60
+ if (page.after !== undefined)
61
+ query.set("after", page.after);
62
+ if (page.limit !== undefined)
63
+ query.set("limit", String(page.limit));
64
+ return call("GET", `blob-owners?${query}`);
65
+ },
57
66
  };
58
67
  }
68
+ export async function* allBlobOwners(client, prefix = "", pageSize) {
69
+ let after;
70
+ do {
71
+ const page = await client.listOwners(prefix, { after, limit: pageSize });
72
+ yield* page.owners;
73
+ after = page.nextToken;
74
+ } while (after !== undefined);
75
+ }
59
76
  export async function casUpdate(client, owner, key, change, maxAttempts = 5) {
60
77
  for (let attempt = 1;; attempt++) {
61
78
  const current = await client.get(owner, key);
@@ -4,9 +4,10 @@ export { servePlugin, createPluginApp } from "./servePlugin";
4
4
  export { createRegistrationLoop } from "./registration";
5
5
  export { runConformance, assertConformance } from "./conformance";
6
6
  export { PLUGIN_PROTOCOL_VERSION, runtimeVersion } from "./version";
7
- export { createBlobClient, casUpdate, blobOwner, BlobStoreError, isVersionConflict } from "./blobClient";
7
+ export { createBlobClient, casUpdate, blobOwner, allBlobOwners, BlobStoreError, isVersionConflict } from "./blobClient";
8
8
  export { attachLive } from "./live/server";
9
9
  export { createLiveHarness } from "./live/harness";
10
10
  export { loadRoomCheckpoint } from "./live/checkpoint";
11
11
  export { runLiveConformance, assertLiveConformance } from "./live/conformance";
12
12
  export { LIVE_PROTOCOL_VERSION, LIVE_PATH, LinkCloseCode, MAX_LINK_FRAME_BYTES } from "./live/protocol";
13
+ export { createSpaceClient, createMembershipGuard, isAlreadyASpace, SpaceApiError, NotASpaceMemberError, FakeSpaceClient, MAX_MEMBERSHIP_AGE_MS, } from "./spaceClient";
@@ -0,0 +1,205 @@
1
+ export class SpaceApiError extends Error {
2
+ type;
3
+ status;
4
+ details;
5
+ constructor(type, status, details) {
6
+ super(`${type}: ${String(details.message ?? status)}`);
7
+ this.type = type;
8
+ this.status = status;
9
+ this.details = details;
10
+ this.name = "SpaceApiError";
11
+ }
12
+ }
13
+ export function isAlreadyASpace(e) {
14
+ return e instanceof SpaceApiError && e.type === "SpaceAlreadyExists";
15
+ }
16
+ const ERROR_TYPES = new Set([
17
+ "SpaceNotFound", "InvalidSpaceImport", "SpaceImportDisabled", "SpaceAlreadyExists", "InviteCodeTaken", "PluginUnauthorized",
18
+ ]);
19
+ export function createSpaceClient(options) {
20
+ const doFetch = options.fetchImpl ?? fetch;
21
+ const timeoutMs = options.timeoutMs ?? 5000;
22
+ const base = `${options.platformUrl}/internal/plugins/${encodeURIComponent(options.gameType)}`;
23
+ async function call(method, path, body) {
24
+ const headers = {};
25
+ if (options.authToken)
26
+ headers["X-Plugin-Auth"] = `Bearer ${options.authToken}`;
27
+ if (body !== undefined)
28
+ headers["Content-Type"] = "application/json";
29
+ const res = await doFetch(`${base}/${path}`, {
30
+ method,
31
+ headers,
32
+ body: body === undefined ? undefined : JSON.stringify(body),
33
+ signal: AbortSignal.timeout(timeoutMs),
34
+ });
35
+ const text = await res.text();
36
+ let parsed = {};
37
+ try {
38
+ parsed = text ? JSON.parse(text) : {};
39
+ }
40
+ catch {
41
+ }
42
+ if (res.ok)
43
+ return parsed;
44
+ const type = (res.headers.get("X-Amzn-Errortype") ?? parsed.__type ?? "");
45
+ if (ERROR_TYPES.has(type))
46
+ throw new SpaceApiError(type, res.status, parsed);
47
+ throw new Error(`spaces ${method} ${path}: ${res.status} ${text.slice(0, 200)}`);
48
+ }
49
+ return {
50
+ get: async (spaceId) => {
51
+ try {
52
+ return await call("GET", `spaces/${encodeURIComponent(spaceId)}`);
53
+ }
54
+ catch (e) {
55
+ if (e instanceof SpaceApiError && e.type === "SpaceNotFound")
56
+ return null;
57
+ throw e;
58
+ }
59
+ },
60
+ import: (spaceId, body) => call("PUT", `spaces/${encodeURIComponent(spaceId)}/import`, body),
61
+ counts: () => call("GET", "space-counts"),
62
+ };
63
+ }
64
+ export const MAX_MEMBERSHIP_AGE_MS = 15_000;
65
+ export class NotASpaceMemberError extends Error {
66
+ spaceId;
67
+ playerId;
68
+ constructor(spaceId, playerId) {
69
+ super(`${playerId} is not an ACTIVE member of Space ${spaceId}`);
70
+ this.spaceId = spaceId;
71
+ this.playerId = playerId;
72
+ this.name = "NotASpaceMemberError";
73
+ }
74
+ }
75
+ export function createMembershipGuard(client, options = {}) {
76
+ const maxAgeMs = Math.min(options.maxAgeMs ?? MAX_MEMBERSHIP_AGE_MS, MAX_MEMBERSHIP_AGE_MS);
77
+ const now = options.now ?? Date.now;
78
+ const cache = new Map();
79
+ function space(spaceId) {
80
+ const t = now();
81
+ const cached = cache.get(spaceId);
82
+ if (cached && t - cached.at < maxAgeMs)
83
+ return cached.read;
84
+ for (const [id, entry] of cache)
85
+ if (t - entry.at >= maxAgeMs)
86
+ cache.delete(id);
87
+ const read = client.get(spaceId);
88
+ cache.set(spaceId, { at: t, read });
89
+ const forget = () => {
90
+ if (cache.get(spaceId)?.read === read)
91
+ cache.delete(spaceId);
92
+ };
93
+ read.then((s) => { if (s === null)
94
+ forget(); }, forget);
95
+ return read;
96
+ }
97
+ const isMember = (s, playerId) => s !== null && s.members.some((m) => m.playerId === playerId && m.status === "ACTIVE");
98
+ return {
99
+ space,
100
+ isActiveMember: async (spaceId, playerId) => isMember(await space(spaceId), playerId),
101
+ assertActiveMember: async (spaceId, playerId) => {
102
+ const s = await space(spaceId);
103
+ if (!isMember(s, playerId))
104
+ throw new NotASpaceMemberError(spaceId, playerId);
105
+ return s;
106
+ },
107
+ invalidate: (spaceId) => (spaceId === undefined ? cache.clear() : void cache.delete(spaceId)),
108
+ };
109
+ }
110
+ export class FakeSpaceClient {
111
+ options;
112
+ spaces = new Map();
113
+ calls = [];
114
+ names = new Map();
115
+ importEnabled = true;
116
+ constructor(options = {}) {
117
+ this.options = options;
118
+ }
119
+ add(space) {
120
+ const at = new Date(0).toISOString();
121
+ this.spaces.set(space.id, {
122
+ space: { id: space.id, name: space.name ?? "Space", ownerId: space.ownerId, settings: space.settings ?? {}, archived: false, revision: 1 },
123
+ inviteCode: space.inviteCode ? normalizeCode(space.inviteCode) : null,
124
+ members: new Map([[space.ownerId, { playerId: space.ownerId, role: "OWNER", status: "ACTIVE", joinedAt: at, statusAt: at }]]),
125
+ });
126
+ }
127
+ setStatus(spaceId, playerId, status) {
128
+ const s = this.spaces.get(spaceId);
129
+ if (!s)
130
+ throw new Error(`no Space ${spaceId}`);
131
+ const at = new Date().toISOString();
132
+ const current = s.members.get(playerId);
133
+ s.members.set(playerId, current ? { ...current, status, statusAt: at } : { playerId, role: "MEMBER", status, joinedAt: at, statusAt: at });
134
+ s.space.revision++;
135
+ s.imported = undefined;
136
+ }
137
+ archive(spaceId) {
138
+ const s = this.spaces.get(spaceId);
139
+ if (!s)
140
+ throw new Error(`no Space ${spaceId}`);
141
+ s.space.archived = true;
142
+ s.space.revision++;
143
+ s.imported = undefined;
144
+ }
145
+ async get(spaceId) {
146
+ this.calls.push(`get ${spaceId}`);
147
+ const s = this.spaces.get(spaceId);
148
+ return s ? this.view(s) : null;
149
+ }
150
+ async import(spaceId, body) {
151
+ this.calls.push(`import ${spaceId}`);
152
+ const refuse = (type, status, details = {}) => {
153
+ throw new SpaceApiError(type, status, { message: type, ...details });
154
+ };
155
+ if (!this.importEnabled)
156
+ refuse("SpaceImportDisabled", 403);
157
+ const owners = body.members.filter((m) => m.role === "OWNER");
158
+ if (owners.length !== 1 || owners[0].playerId !== body.ownerId || owners[0].status !== "ACTIVE") {
159
+ refuse("InvalidSpaceImport", 400, { message: "exactly one ACTIVE OWNER, who is ownerId" });
160
+ }
161
+ const code = body.inviteCode ? normalizeCode(body.inviteCode) : null;
162
+ const key = JSON.stringify({ ...body, inviteCode: code, settings: body.settings ?? {} });
163
+ const existing = this.spaces.get(spaceId);
164
+ if (existing) {
165
+ if (existing.imported === key)
166
+ return { created: false, space: this.view(existing) };
167
+ refuse("SpaceAlreadyExists", 409);
168
+ }
169
+ if (this.options.players) {
170
+ const known = new Set(this.options.players);
171
+ const unknown = [...new Set([body.ownerId, ...body.members.map((m) => m.playerId)])].filter((p) => !known.has(p));
172
+ if (unknown.length > 0)
173
+ refuse("InvalidSpaceImport", 400, { unknownPlayerIds: unknown });
174
+ }
175
+ if (code && [...this.spaces.values()].some((s) => s.inviteCode === code))
176
+ refuse("InviteCodeTaken", 409);
177
+ const stored = {
178
+ space: { id: spaceId, name: body.name.trim(), ownerId: body.ownerId, settings: body.settings ?? {}, archived: false, revision: 1 },
179
+ inviteCode: code,
180
+ members: new Map(body.members.map((m) => [m.playerId, { ...m }])),
181
+ imported: key,
182
+ };
183
+ this.spaces.set(spaceId, stored);
184
+ return { created: true, space: this.view(stored) };
185
+ }
186
+ async counts() {
187
+ this.calls.push("counts");
188
+ const all = [...this.spaces.values()];
189
+ return {
190
+ spaces: all.length,
191
+ inviteCodes: all.filter((s) => s.inviteCode !== null).length,
192
+ activeMembers: all.reduce((n, s) => n + [...s.members.values()].filter((m) => m.status === "ACTIVE").length, 0),
193
+ };
194
+ }
195
+ view(s) {
196
+ const members = [...s.members.values()]
197
+ .filter((m) => m.status === "ACTIVE")
198
+ .sort((a, b) => (a.role === b.role ? a.joinedAt.localeCompare(b.joinedAt) : a.role === "OWNER" ? -1 : 1))
199
+ .map((m) => ({ playerId: m.playerId, displayName: this.names.get(m.playerId), role: m.role, status: "ACTIVE" }));
200
+ return { ...s.space, settings: { ...s.space.settings }, members };
201
+ }
202
+ }
203
+ function normalizeCode(code) {
204
+ return code.toUpperCase().replace(/[\s\-_.]/g, "");
205
+ }
@@ -406,6 +406,108 @@ export var GameStatusOutput;
406
406
  ];
407
407
  };
408
408
  })(GameStatusOutput || (GameStatusOutput = {}));
409
+ export const LiveMode = {
410
+ HOSTED: "HOSTED",
411
+ RELAY: "RELAY",
412
+ };
413
+ export const LiveScope = {
414
+ GAME: "GAME",
415
+ SPACE: "SPACE",
416
+ };
417
+ export var LiveCapability;
418
+ (function (LiveCapability) {
419
+ const memberValidators = {};
420
+ LiveCapability.validate = (obj, path = "") => {
421
+ function getMemberValidator(member) {
422
+ if (memberValidators[member] === undefined) {
423
+ switch (member) {
424
+ case "mode": {
425
+ memberValidators["mode"] = new __CompositeValidator([
426
+ new __EnumValidator([
427
+ "RELAY",
428
+ "HOSTED",
429
+ ], [
430
+ "RELAY",
431
+ "HOSTED",
432
+ ]),
433
+ new __RequiredValidator(),
434
+ ]);
435
+ break;
436
+ }
437
+ case "scope": {
438
+ memberValidators["scope"] = new __CompositeValidator([
439
+ new __EnumValidator([
440
+ "GAME",
441
+ "SPACE",
442
+ ], [
443
+ "GAME",
444
+ "SPACE",
445
+ ]),
446
+ ]);
447
+ break;
448
+ }
449
+ case "spectators": {
450
+ memberValidators["spectators"] = new __NoOpValidator();
451
+ break;
452
+ }
453
+ case "maxSignalsPerSecond": {
454
+ memberValidators["maxSignalsPerSecond"] = new __NoOpValidator();
455
+ break;
456
+ }
457
+ case "maxSignalBytes": {
458
+ memberValidators["maxSignalBytes"] = new __NoOpValidator();
459
+ break;
460
+ }
461
+ }
462
+ }
463
+ return memberValidators[member];
464
+ }
465
+ return [
466
+ ...getMemberValidator("mode").validate(obj.mode, `${path}/mode`),
467
+ ...getMemberValidator("scope").validate(obj.scope, `${path}/scope`),
468
+ ...getMemberValidator("spectators").validate(obj.spectators, `${path}/spectators`),
469
+ ...getMemberValidator("maxSignalsPerSecond").validate(obj.maxSignalsPerSecond, `${path}/maxSignalsPerSecond`),
470
+ ...getMemberValidator("maxSignalBytes").validate(obj.maxSignalBytes, `${path}/maxSignalBytes`),
471
+ ];
472
+ };
473
+ })(LiveCapability || (LiveCapability = {}));
474
+ export var SpaceCapability;
475
+ (function (SpaceCapability) {
476
+ const memberValidators = {};
477
+ SpaceCapability.validate = (obj, path = "") => {
478
+ function getMemberValidator(member) {
479
+ if (memberValidators[member] === undefined) {
480
+ switch (member) {
481
+ case "maxMembers": {
482
+ memberValidators["maxMembers"] = new __CompositeValidator([
483
+ new __RequiredValidator(),
484
+ ]);
485
+ break;
486
+ }
487
+ case "label": {
488
+ memberValidators["label"] = new __NoOpValidator();
489
+ break;
490
+ }
491
+ case "settingsSchema": {
492
+ memberValidators["settingsSchema"] = new __NoOpValidator();
493
+ break;
494
+ }
495
+ case "gameConfig": {
496
+ memberValidators["gameConfig"] = new __NoOpValidator();
497
+ break;
498
+ }
499
+ }
500
+ }
501
+ return memberValidators[member];
502
+ }
503
+ return [
504
+ ...getMemberValidator("maxMembers").validate(obj.maxMembers, `${path}/maxMembers`),
505
+ ...getMemberValidator("label").validate(obj.label, `${path}/label`),
506
+ ...getMemberValidator("settingsSchema").validate(obj.settingsSchema, `${path}/settingsSchema`),
507
+ ...getMemberValidator("gameConfig").validate(obj.gameConfig, `${path}/gameConfig`),
508
+ ];
509
+ };
510
+ })(SpaceCapability || (SpaceCapability = {}));
409
511
  export var GameTypeMetadata;
410
512
  (function (GameTypeMetadata) {
411
513
  const memberValidators = {};
@@ -485,6 +587,14 @@ export var GameTypeMetadata;
485
587
  memberValidators["hasSaveFileEditor"] = new __NoOpValidator();
486
588
  break;
487
589
  }
590
+ case "spaces": {
591
+ memberValidators["spaces"] = new __CompositeStructureValidator(new __NoOpValidator(), SpaceCapability.validate);
592
+ break;
593
+ }
594
+ case "live": {
595
+ memberValidators["live"] = new __CompositeStructureValidator(new __NoOpValidator(), LiveCapability.validate);
596
+ break;
597
+ }
488
598
  }
489
599
  }
490
600
  return memberValidators[member];
@@ -505,6 +615,8 @@ export var GameTypeMetadata;
505
615
  ...getMemberValidator("hasSaveFiles").validate(obj.hasSaveFiles, `${path}/hasSaveFiles`),
506
616
  ...getMemberValidator("hasSaveSummary").validate(obj.hasSaveSummary, `${path}/hasSaveSummary`),
507
617
  ...getMemberValidator("hasSaveFileEditor").validate(obj.hasSaveFileEditor, `${path}/hasSaveFileEditor`),
618
+ ...getMemberValidator("spaces").validate(obj.spaces, `${path}/spaces`),
619
+ ...getMemberValidator("live").validate(obj.live, `${path}/live`),
508
620
  ];
509
621
  };
510
622
  })(GameTypeMetadata || (GameTypeMetadata = {}));
@@ -616,8 +616,10 @@ export const serializeGetMetadataResponse = async (input, ctx) => {
616
616
  'hasSaveFiles': [],
617
617
  'hasSaveSummary': [],
618
618
  'id': [],
619
+ 'live': _ => se_LiveCapability(_, context),
619
620
  'maxPlayers': [],
620
621
  'minPlayers': [],
622
+ 'spaces': _ => se_SpaceCapability(_, context),
621
623
  'spectatorSafe': [],
622
624
  'teamPlay': [],
623
625
  }));
@@ -1015,9 +1017,26 @@ const se_BotSkillLevelList = (input, context) => {
1015
1017
  return se_BotSkillLevelEntry(entry, context);
1016
1018
  });
1017
1019
  };
1020
+ const se_LiveCapability = (input, context) => {
1021
+ return take(input, {
1022
+ 'maxSignalBytes': [],
1023
+ 'maxSignalsPerSecond': [],
1024
+ 'mode': [],
1025
+ 'scope': [],
1026
+ 'spectators': [],
1027
+ });
1028
+ };
1018
1029
  const se_ParticipantIdList = (input, context) => {
1019
1030
  return input.filter((e) => e != null);
1020
1031
  };
1032
+ const se_SpaceCapability = (input, context) => {
1033
+ return take(input, {
1034
+ 'gameConfig': _ => se_Document(_, context),
1035
+ 'label': [],
1036
+ 'maxMembers': [],
1037
+ 'settingsSchema': _ => se_Document(_, context),
1038
+ });
1039
+ };
1021
1040
  const se_Document = (input, context) => {
1022
1041
  return input;
1023
1042
  };