@anweather/game-plugin-ssdk 1.17.0 → 1.18.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,43 @@ export var GameStatusOutput;
406
406
  ];
407
407
  };
408
408
  })(GameStatusOutput || (GameStatusOutput = {}));
409
+ export var SpaceCapability;
410
+ (function (SpaceCapability) {
411
+ const memberValidators = {};
412
+ SpaceCapability.validate = (obj, path = "") => {
413
+ function getMemberValidator(member) {
414
+ if (memberValidators[member] === undefined) {
415
+ switch (member) {
416
+ case "maxMembers": {
417
+ memberValidators["maxMembers"] = new __CompositeValidator([
418
+ new __RequiredValidator(),
419
+ ]);
420
+ break;
421
+ }
422
+ case "label": {
423
+ memberValidators["label"] = new __NoOpValidator();
424
+ break;
425
+ }
426
+ case "settingsSchema": {
427
+ memberValidators["settingsSchema"] = new __NoOpValidator();
428
+ break;
429
+ }
430
+ case "gameConfig": {
431
+ memberValidators["gameConfig"] = new __NoOpValidator();
432
+ break;
433
+ }
434
+ }
435
+ }
436
+ return memberValidators[member];
437
+ }
438
+ return [
439
+ ...getMemberValidator("maxMembers").validate(obj.maxMembers, `${path}/maxMembers`),
440
+ ...getMemberValidator("label").validate(obj.label, `${path}/label`),
441
+ ...getMemberValidator("settingsSchema").validate(obj.settingsSchema, `${path}/settingsSchema`),
442
+ ...getMemberValidator("gameConfig").validate(obj.gameConfig, `${path}/gameConfig`),
443
+ ];
444
+ };
445
+ })(SpaceCapability || (SpaceCapability = {}));
409
446
  export var GameTypeMetadata;
410
447
  (function (GameTypeMetadata) {
411
448
  const memberValidators = {};
@@ -485,6 +522,10 @@ export var GameTypeMetadata;
485
522
  memberValidators["hasSaveFileEditor"] = new __NoOpValidator();
486
523
  break;
487
524
  }
525
+ case "spaces": {
526
+ memberValidators["spaces"] = new __CompositeStructureValidator(new __NoOpValidator(), SpaceCapability.validate);
527
+ break;
528
+ }
488
529
  }
489
530
  }
490
531
  return memberValidators[member];
@@ -505,6 +546,7 @@ export var GameTypeMetadata;
505
546
  ...getMemberValidator("hasSaveFiles").validate(obj.hasSaveFiles, `${path}/hasSaveFiles`),
506
547
  ...getMemberValidator("hasSaveSummary").validate(obj.hasSaveSummary, `${path}/hasSaveSummary`),
507
548
  ...getMemberValidator("hasSaveFileEditor").validate(obj.hasSaveFileEditor, `${path}/hasSaveFileEditor`),
549
+ ...getMemberValidator("spaces").validate(obj.spaces, `${path}/spaces`),
508
550
  ];
509
551
  };
510
552
  })(GameTypeMetadata || (GameTypeMetadata = {}));
@@ -618,6 +618,7 @@ export const serializeGetMetadataResponse = async (input, ctx) => {
618
618
  'id': [],
619
619
  'maxPlayers': [],
620
620
  'minPlayers': [],
621
+ 'spaces': _ => se_SpaceCapability(_, context),
621
622
  'spectatorSafe': [],
622
623
  'teamPlay': [],
623
624
  }));
@@ -1018,6 +1019,14 @@ const se_BotSkillLevelList = (input, context) => {
1018
1019
  const se_ParticipantIdList = (input, context) => {
1019
1020
  return input.filter((e) => e != null);
1020
1021
  };
1022
+ const se_SpaceCapability = (input, context) => {
1023
+ return take(input, {
1024
+ 'gameConfig': _ => se_Document(_, context),
1025
+ 'label': [],
1026
+ 'maxMembers': [],
1027
+ 'settingsSchema': _ => se_Document(_, context),
1028
+ });
1029
+ };
1021
1030
  const se_Document = (input, context) => {
1022
1031
  return input;
1023
1032
  };