@anweather/game-plugin-ssdk 1.16.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.
Files changed (47) hide show
  1. package/dist-cjs/runtime/auth.js +8 -0
  2. package/dist-cjs/runtime/blobClient.js +20 -2
  3. package/dist-cjs/runtime/conformance.js +6 -0
  4. package/dist-cjs/runtime/index.js +24 -1
  5. package/dist-cjs/runtime/live/checkpoint.js +31 -0
  6. package/dist-cjs/runtime/live/conformance.js +215 -0
  7. package/dist-cjs/runtime/live/harness.js +144 -0
  8. package/dist-cjs/runtime/live/outbox.js +72 -0
  9. package/dist-cjs/runtime/live/protocol.js +227 -0
  10. package/dist-cjs/runtime/live/rooms.js +579 -0
  11. package/dist-cjs/runtime/live/server.js +105 -0
  12. package/dist-cjs/runtime/servePlugin.js +8 -2
  13. package/dist-cjs/runtime/spaceClient.js +214 -0
  14. package/dist-cjs/src/models/models_0.js +43 -1
  15. package/dist-cjs/src/protocols/Aws_restJson1.js +9 -0
  16. package/dist-cjs/tsconfig.cjs.tsbuildinfo +1 -1
  17. package/dist-es/runtime/auth.js +5 -0
  18. package/dist-es/runtime/blobClient.js +19 -2
  19. package/dist-es/runtime/conformance.js +6 -0
  20. package/dist-es/runtime/index.js +7 -1
  21. package/dist-es/runtime/live/checkpoint.js +28 -0
  22. package/dist-es/runtime/live/conformance.js +210 -0
  23. package/dist-es/runtime/live/harness.js +141 -0
  24. package/dist-es/runtime/live/outbox.js +68 -0
  25. package/dist-es/runtime/live/protocol.js +217 -0
  26. package/dist-es/runtime/live/rooms.js +575 -0
  27. package/dist-es/runtime/live/server.js +102 -0
  28. package/dist-es/runtime/servePlugin.js +8 -2
  29. package/dist-es/runtime/spaceClient.js +205 -0
  30. package/dist-es/src/models/models_0.js +42 -0
  31. package/dist-es/src/protocols/Aws_restJson1.js +9 -0
  32. package/dist-es/tsconfig.es.tsbuildinfo +1 -1
  33. package/dist-types/runtime/auth.d.ts +3 -0
  34. package/dist-types/runtime/blobClient.d.ts +27 -2
  35. package/dist-types/runtime/conformance.d.ts +6 -0
  36. package/dist-types/runtime/index.d.ts +15 -2
  37. package/dist-types/runtime/live/checkpoint.d.ts +15 -0
  38. package/dist-types/runtime/live/conformance.d.ts +24 -0
  39. package/dist-types/runtime/live/harness.d.ts +71 -0
  40. package/dist-types/runtime/live/outbox.d.ts +39 -0
  41. package/dist-types/runtime/live/protocol.d.ts +169 -0
  42. package/dist-types/runtime/live/rooms.d.ts +302 -0
  43. package/dist-types/runtime/live/server.d.ts +36 -0
  44. package/dist-types/runtime/servePlugin.d.ts +8 -0
  45. package/dist-types/runtime/spaceClient.d.ts +159 -0
  46. package/dist-types/src/models/models_0.d.ts +23 -0
  47. package/package.json +6 -3
@@ -0,0 +1,302 @@
1
+ import { type PlatformFrame, type PluginFrame, type PresenceState, type Role, type RoomScope, type SpaceMember } from "./protocol";
2
+ import { Outbox, type LinkWriter, type OutboxOptions, type OutboxStats } from "./outbox";
3
+ type Awaitable<T> = T | Promise<T>;
4
+ /** Why a room was discarded, as RoomHandlers.onClose hears it. */
5
+ export type RoomCloseReason =
6
+ /** The platform closed the last link with 1000: no client for its idle grace. */
7
+ "idle"
8
+ /** The platform closed with 4410: the Game behind the scope is over. */
9
+ | "ended"
10
+ /** This process is stopping (SIGTERM or RunningPlugin.close()). The
11
+ * checkpoint hook: clients are still connected and will get a fresh room
12
+ * from the next container (design §8.4). */
13
+ | "shutdown"
14
+ /** Every link dropped without a planned close, and none came back within
15
+ * reattachGraceMs. */
16
+ | "lost"
17
+ /** The room called room.close() itself. */
18
+ | "closed"
19
+ /** onRoom threw, or the room fell maxPendingEvents behind. */
20
+ | "error";
21
+ export interface Conn {
22
+ /** Unique within the room (the platform's conn id, namespaced by link). */
23
+ readonly id: string;
24
+ readonly participantId: string;
25
+ readonly playerId: string;
26
+ readonly displayName: string;
27
+ readonly role: Role;
28
+ /** The participant's own Game; in a Space room each player has their own. */
29
+ readonly gameId: string;
30
+ /** The X-Live-Epoch of the link that carries this connection: the
31
+ * platform channel's incarnation. A new epoch means the platform
32
+ * restarted and every client is resyncing. */
33
+ readonly epoch: string;
34
+ /** The player appears offline (design §3.2): a room must not reveal them
35
+ * to the other members. Fixed for the connection's life: a player who
36
+ * toggles "Appear offline" reconnects, and the new conn carries the new
37
+ * value. */
38
+ readonly hidden: boolean;
39
+ readonly presence: PresenceState;
40
+ /** False once the connection has left. Sends to it are then dropped. */
41
+ readonly open: boolean;
42
+ send(data: unknown, opts?: SendOptions): void;
43
+ kick(opts: KickOptions): void;
44
+ }
45
+ export interface SendOptions {
46
+ /** Coalescing key: under backpressure a newer frame with the same key
47
+ * replaces an undelivered older one, here and on the platform. */
48
+ k?: string;
49
+ }
50
+ export interface BroadcastOptions extends SendOptions {
51
+ /** Connections to leave out (typically the sender of what is echoed). */
52
+ except?: Conn | readonly Conn[];
53
+ /** Leave spectators out. */
54
+ participantsOnly?: boolean;
55
+ }
56
+ export interface KickOptions {
57
+ /** Required, so "may this client come back" is always an explicit
58
+ * choice: 4403 keeps it out (terminal); 4900-4999 are the plugin's own
59
+ * and the client reconnects after them. */
60
+ code: number;
61
+ reason?: string;
62
+ }
63
+ export interface RoomTimer {
64
+ cancel(): void;
65
+ }
66
+ export interface Room {
67
+ readonly scope: RoomScope;
68
+ readonly gameType: string;
69
+ /** True once the room has been discarded; every call becomes a no-op. */
70
+ readonly closed: boolean;
71
+ /** Open connections, in join order. */
72
+ conns(): readonly Conn[];
73
+ /** Participant ids with at least one open connection (spectators excluded). */
74
+ participants(): string[];
75
+ connsOf(participantId: string): readonly Conn[];
76
+ /** The latest `members` frame (Space scope only), else undefined. */
77
+ members(): readonly SpaceMember[] | undefined;
78
+ broadcast(data: unknown, opts?: BroadcastOptions): void;
79
+ send(to: Conn | readonly Conn[], data: unknown, opts?: SendOptions): void;
80
+ /** Every open connection of one participant (several tabs are allowed). */
81
+ sendToParticipant(participantId: string, data: unknown, opts?: SendOptions): void;
82
+ kick(conn: Conn, opts: KickOptions): void;
83
+ /** Runs `fn` through the room's queue after `ms`; cancelled with the room. */
84
+ setTimeout(fn: () => Awaitable<void>, ms: number): RoomTimer;
85
+ /** Runs `fn` through the queue every `ms`. A tick is skipped while the
86
+ * previous one is still queued or running, so a slow room never builds a
87
+ * backlog of ticks. */
88
+ setInterval(fn: () => Awaitable<void>, ms: number): RoomTimer;
89
+ /** Runs `fn` through the room's queue as soon as it is free: the way to act
90
+ * on the room from outside a handler (a blob read finishing, a message
91
+ * from elsewhere in the process) without racing its handlers. Dropped if
92
+ * the room has closed. */
93
+ post(fn: () => Awaitable<void>): void;
94
+ /** Discard this room: onClose("closed"), then its links close with 1000.
95
+ * The platform reopens a link while clients remain, and that gets a fresh
96
+ * room -- so this is "restart from durable state", not "end the game"
97
+ * (only a Move ends a Game). */
98
+ close(reason?: string): void;
99
+ }
100
+ export interface RoomHandlers {
101
+ onJoin?(conn: Conn): Awaitable<void>;
102
+ /** `reason` is the platform's (closed, kicked, timeout, removed...), or
103
+ * the runtime's own: "link" when the link that carried the connection
104
+ * closed or was superseded, "rejoined" when the platform sent a second
105
+ * `join` for a conn id it had already joined (the old Conn leaves, the new
106
+ * one joins). Not debounced: apply a grace with room.setTimeout if a party
107
+ * should survive a blip. */
108
+ onLeave?(conn: Conn, reason: string): Awaitable<void>;
109
+ /** A client's Signal. `data` is untrusted: the sender is `conn`, anything
110
+ * inside `data` is a claim (design §7.2). In a Space room, a signal from a
111
+ * member removed a moment ago can still be queued ahead of their `leave`;
112
+ * check room.members() before letting it change shared state. */
113
+ onMessage?(conn: Conn, data: unknown): Awaitable<void>;
114
+ onPresence?(conn: Conn, state: PresenceState): Awaitable<void>;
115
+ /** A Move was committed on a Game in this scope. */
116
+ onGame?(gameId: string, revision: number): Awaitable<void>;
117
+ /** Space membership changed. Removed members' connections leave right after. */
118
+ onMembers?(members: readonly SpaceMember[]): Awaitable<void>;
119
+ /** The room is being discarded; the last chance to write a checkpoint. */
120
+ onClose?(reason: RoomCloseReason): Awaitable<void>;
121
+ }
122
+ export interface LiveOptions {
123
+ /** Called once per room, when the first link for its scope arrives. Load
124
+ * state here (from blobs); the runtime answers `ready` when it resolves and
125
+ * holds every event until then. Per-room state lives in this closure. */
126
+ onRoom(room: Room): Awaitable<RoomHandlers | void>;
127
+ /** How long a room whose links all dropped unexpectedly (a platform
128
+ * restart, a network cut) is kept for a new link to the same scope.
129
+ * Default 60 s. */
130
+ reattachGraceMs?: number;
131
+ /** Events queued behind a slow room before it is failed with 1011 (the
132
+ * platform then opens a fresh one). Default 4096. */
133
+ maxPendingEvents?: number;
134
+ /** How long a discarded room gets to finish the handler in flight, drain
135
+ * and run onClose, whatever the reason (shutdown included); also how long
136
+ * a successor room for the same scope waits for that before loading
137
+ * anyway. A handler that never resolves can delay a scope by this much,
138
+ * never brick it. Default 5 s. */
139
+ shutdownTimeoutMs?: number;
140
+ /** A handler still running after this long is reported through onError
141
+ * (once per call) as a stall. It is not cancelled: JS can't. Default 10 s;
142
+ * 0 turns the watchdog off. */
143
+ stallWarningMs?: number;
144
+ /** Plugin -> platform backpressure, per link. */
145
+ outbox?: OutboxOptions;
146
+ /** A handler threw or stalled. Default: log it. The room carries on. An
147
+ * onError that throws is itself caught and logged. */
148
+ onError?(error: unknown, where: {
149
+ scope: string;
150
+ handler: string;
151
+ }): void;
152
+ }
153
+ export interface LinkInfo {
154
+ scope: RoomScope;
155
+ epoch: string;
156
+ }
157
+ /** The socket side of a link, supplied by server.ts or the harness. */
158
+ export interface LinkTransport extends LinkWriter {
159
+ close(code: number, reason: string): void;
160
+ }
161
+ /** What the transport calls back into. */
162
+ export interface LinkHandle {
163
+ receive(text: string): void;
164
+ /** The socket closed (either side). Idempotent. */
165
+ closed(code: number): void;
166
+ }
167
+ export interface RoomHostOptions {
168
+ gameType: string;
169
+ live: LiveOptions;
170
+ log?: Pick<Console, "log" | "error">;
171
+ }
172
+ declare class Link {
173
+ readonly serial: number;
174
+ readonly info: LinkInfo;
175
+ readonly transport: LinkTransport;
176
+ readonly outbox: Outbox;
177
+ readonly conns: Map<string, ConnImpl>;
178
+ ready: boolean;
179
+ detached: boolean;
180
+ constructor(serial: number, info: LinkInfo, transport: LinkTransport, outbox: OutboxOptions | undefined);
181
+ write(frame: PluginFrame, key?: string): void;
182
+ }
183
+ declare class ConnImpl implements Conn {
184
+ readonly link: Link;
185
+ readonly wire: string;
186
+ readonly participantId: string;
187
+ readonly playerId: string;
188
+ readonly displayName: string;
189
+ readonly role: Role;
190
+ readonly gameId: string;
191
+ readonly hidden: boolean;
192
+ private readonly room;
193
+ open: boolean;
194
+ presence: PresenceState;
195
+ constructor(link: Link, wire: string, participantId: string, playerId: string, displayName: string, role: Role, gameId: string, hidden: boolean, presence: PresenceState, room: RoomImpl);
196
+ get epoch(): string;
197
+ get id(): string;
198
+ send(data: unknown, opts?: SendOptions): void;
199
+ kick(opts: KickOptions): void;
200
+ }
201
+ declare class RoomImpl implements Room {
202
+ readonly scope: RoomScope;
203
+ readonly gameType: string;
204
+ private readonly host;
205
+ private readonly live;
206
+ readonly links: Set<Link>;
207
+ private readonly order;
208
+ private handlers;
209
+ private latestMembers;
210
+ private readonly queue;
211
+ private processing;
212
+ private loaded;
213
+ private disposing;
214
+ private readonly timers;
215
+ private graceTimer;
216
+ private idleWaiters;
217
+ private loading;
218
+ closed: boolean;
219
+ constructor(scope: RoomScope, gameType: string, host: RoomHost, live: LiveOptions);
220
+ load(previous: Promise<void> | undefined): Promise<void>;
221
+ private runLoad;
222
+ linkWithEpoch(epoch: string): Link | undefined;
223
+ markReady(link: Link): void;
224
+ attach(link: Link): void;
225
+ /** A newer link with the same epoch replaced `old` (PROTOCOL.md §5.4): the
226
+ * platform has abandoned it, so close it now and let its conns leave. */
227
+ supersede(old: Link): void;
228
+ /** A link's socket went away. Its connections leave through the queue. */
229
+ detach(link: Link, code: number): void;
230
+ /** Discard the room: stop timers, let pending events finish (unless
231
+ * failing), run onClose, close any remaining links. Idempotent.
232
+ *
233
+ * Everything after the timers stop is bounded by closeTimeout(): a
234
+ * handler or onClose that never resolves delays the links' close by that
235
+ * much, never forever. A failing room ("error") closes its links first,
236
+ * at once, so the platform can open a fresh room while this one winds
237
+ * down. */
238
+ dispose(reason: RoomCloseReason, code?: number, closeReason?: string, opts?: {
239
+ runOnClose?: boolean;
240
+ drainFirst?: boolean;
241
+ }): Promise<void>;
242
+ private closeLinks;
243
+ closeTimeout(): number;
244
+ enqueue(name: string, run: () => Awaitable<void>): void;
245
+ private drain;
246
+ /** Resolves when nothing is queued or running. */
247
+ whenIdle(): Promise<void>;
248
+ isIdle(): boolean;
249
+ /** Loaded (or discarded) with nothing queued or running: for the harness. */
250
+ isSettled(): boolean;
251
+ private call;
252
+ onFrame(link: Link, frame: PlatformFrame): void;
253
+ private removeConn;
254
+ conns(): readonly Conn[];
255
+ participants(): string[];
256
+ connsOf(participantId: string): readonly Conn[];
257
+ members(): readonly SpaceMember[] | undefined;
258
+ broadcast(data: unknown, opts?: BroadcastOptions): void;
259
+ send(to: Conn | readonly Conn[], data: unknown, opts?: SendOptions): void;
260
+ sendToParticipant(participantId: string, data: unknown, opts?: SendOptions): void;
261
+ kick(conn: Conn, opts: KickOptions): void;
262
+ setTimeout(fn: () => Awaitable<void>, ms: number): RoomTimer;
263
+ setInterval(fn: () => Awaitable<void>, ms: number): RoomTimer;
264
+ close(reason?: string): void;
265
+ post(fn: () => Awaitable<void>): void;
266
+ private timer;
267
+ private assertReady;
268
+ private write;
269
+ outboxStats(): OutboxStats[];
270
+ }
271
+ /** Every room this process hosts. One per servePlugin({live}). */
272
+ export declare class RoomHost {
273
+ private readonly options;
274
+ private readonly rooms;
275
+ /** Rooms being discarded, by scope, so a successor waits for onClose. */
276
+ private readonly disposing;
277
+ private serial;
278
+ private shuttingDown;
279
+ private readonly log;
280
+ constructor(options: RoomHostOptions);
281
+ get gameType(): string;
282
+ /** A new link. Returns the callbacks its socket drives. */
283
+ attach(info: LinkInfo, transport: LinkTransport): LinkHandle;
284
+ /** Rooms currently hosted (not ones being discarded). */
285
+ list(): Room[];
286
+ room(scopeKey: string): Room | undefined;
287
+ /** Every room's outbox counters, for metrics and tests. */
288
+ outboxStats(scopeKey: string): OutboxStats[];
289
+ /** Resolves once every room has loaded and has nothing queued or
290
+ * running, and every discarded room has finished its onClose. For tests:
291
+ * real code never needs to wait for a room. */
292
+ settled(): Promise<void>;
293
+ /** SIGTERM: every room checkpoints (onClose "shutdown", bounded), then its
294
+ * links close with 1012 so the platform reopens them on the next task. */
295
+ shutdown(): Promise<void>;
296
+ /** @internal A room is being discarded: a new link for its scope gets a
297
+ * fresh room, which waits for `done` before loading. */
298
+ forget(room: RoomImpl, done: Promise<void>): void;
299
+ /** @internal */
300
+ reportError(error: unknown, scope: string, handler: string): void;
301
+ }
302
+ export {};
@@ -0,0 +1,36 @@
1
+ import type { Server } from "node:http";
2
+ import { type LiveOptions, type Room } from "./rooms";
3
+ import type { OutboxStats } from "./outbox";
4
+ export interface AttachLiveOptions {
5
+ gameType: string;
6
+ /** The X-Plugin-Auth bearer token; absent accepts every caller (local
7
+ * dev), exactly like the RPC routes. */
8
+ authToken?: string;
9
+ live: LiveOptions;
10
+ /** Protocol ping interval; the link closes after `linkTimeoutMs` with no
11
+ * frame, ping or pong from the platform. Defaults 20 s and 45 s (design
12
+ * §7.4). */
13
+ pingIntervalMs?: number;
14
+ linkTimeoutMs?: number;
15
+ log?: Pick<Console, "log" | "error">;
16
+ }
17
+ export interface LiveServer {
18
+ /** Rooms currently hosted. */
19
+ rooms(): Room[];
20
+ room(scopeKey: string): Room | undefined;
21
+ outboxStats(scopeKey: string): OutboxStats[];
22
+ /** Number of open room links. */
23
+ links(): number;
24
+ /** Text frames read off every link so far, handed to their rooms. Lets a
25
+ * test know the socket has delivered what it sent before it waits for
26
+ * the rooms to settle. */
27
+ framesReceived(): number;
28
+ /** Resolves once every room has loaded and drained its queue (tests). */
29
+ settled(): Promise<void>;
30
+ /** Checkpoint every room (onClose "shutdown"), close links with 1012, and
31
+ * stop accepting upgrades. */
32
+ close(): Promise<void>;
33
+ }
34
+ /** Serve room links on `server`'s /v1/live. servePlugin calls this when
35
+ * given `live`; a plugin with its own http.Server can call it directly. */
36
+ export declare function attachLive(server: Server, options: AttachLiveOptions): LiveServer;
@@ -4,6 +4,8 @@ import type { ValidationCustomizer } from "@aws-smithy/server-common";
4
4
  import type { GamePluginService, GamePluginServiceOperations } from "../src/server/GamePluginService";
5
5
  import type { OptionalGamePluginService, RequiredGamePluginService } from "./defaultService";
6
6
  import { type RegistrationLoop } from "./registration";
7
+ import { type LiveServer } from "./live/server";
8
+ import type { LiveOptions } from "./live/rooms";
7
9
  /** What a plugin supplies: every operation createGamePluginService requires,
8
10
  * except Health, which servePlugin answers itself unless overridden. */
9
11
  export type PluginServiceDefinition<Context> = Omit<RequiredGamePluginService<Context>, "Health"> & Partial<Pick<GamePluginService<Context>, "Health">> & OptionalGamePluginService<Context>;
@@ -41,6 +43,10 @@ export interface ServePluginOptions<Context = Record<string, never>> {
41
43
  retryDelaySeconds?: number;
42
44
  log?: Pick<Console, "log" | "error">;
43
45
  fetchImpl?: typeof fetch;
46
+ /** Host live rooms (Hosted mode, plugin-protocol/live/PROTOCOL.md): the
47
+ * platform opens one room link per active channel to /v1/live on this
48
+ * same server, behind the same X-Plugin-Auth token. Absent: no /v1/live. */
49
+ live?: LiveOptions;
44
50
  }
45
51
  export interface PluginApp {
46
52
  app: Express;
@@ -52,6 +58,8 @@ export interface PluginApp {
52
58
  }
53
59
  export interface RunningPlugin extends PluginApp {
54
60
  server: Server;
61
+ /** Present when `live` was given. */
62
+ live?: LiveServer;
55
63
  /** The port actually bound (differs from the configured one only for 0). */
56
64
  port: number;
57
65
  /** Stop renewing, deregister, close the listener. */
@@ -0,0 +1,159 @@
1
+ export type SpaceRole = "OWNER" | "MEMBER";
2
+ export type SpaceMemberStatus = "ACTIVE" | "LEFT" | "REMOVED";
3
+ /** A member as GetSpace lists one: ACTIVE only, so status is always
4
+ * "ACTIVE". Whoever left or was removed is simply absent. */
5
+ export interface PluginSpaceMember {
6
+ playerId: string;
7
+ /** The platform's display name; absent if the Player is gone. */
8
+ displayName?: string;
9
+ role: SpaceRole;
10
+ status: "ACTIVE";
11
+ }
12
+ /** A Space as `config.space` carries it, plus `archived` and `revision`. */
13
+ export interface PluginSpace<S = Record<string, unknown>> {
14
+ id: string;
15
+ name: string;
16
+ ownerId: string;
17
+ settings: S;
18
+ /** ACTIVE members only, owner first, then by first join. */
19
+ members: PluginSpaceMember[];
20
+ /** Closed by the owner: no new joins or Games; running Games carry on. */
21
+ archived: boolean;
22
+ /** Goes up with every change to the Space. */
23
+ revision: number;
24
+ }
25
+ export interface SpaceImportMember {
26
+ playerId: string;
27
+ role: SpaceRole;
28
+ /** A member who left and later rejoined is ACTIVE. */
29
+ status: SpaceMemberStatus;
30
+ /** ISO-8601: first join. */
31
+ joinedAt: string;
32
+ /** ISO-8601: latest status change. */
33
+ statusAt: string;
34
+ }
35
+ /** What ImportSpace takes: everyone who ever joined, owner included, with
36
+ * exactly one OWNER who is ownerId and ACTIVE. The game type is always
37
+ * this client's. */
38
+ export interface SpaceImport {
39
+ name: string;
40
+ ownerId: string;
41
+ /** "K7QM-4XPA" in any case/spacing; null or absent: closed to joins. */
42
+ inviteCode?: string | null;
43
+ /** A JSON object; absent: {}. */
44
+ settings?: Record<string, unknown>;
45
+ members: SpaceImportMember[];
46
+ }
47
+ export interface SpaceImportResult {
48
+ /** False: an identical import had already landed; nothing was written. */
49
+ created: boolean;
50
+ space: PluginSpace;
51
+ }
52
+ export type SpaceErrorType = "SpaceNotFound" | "InvalidSpaceImport" | "SpaceImportDisabled" | "SpaceAlreadyExists" | "InviteCodeTaken" | "PluginUnauthorized";
53
+ /** A refusal from PluginSpaces. `details` is the error body (for
54
+ * InvalidSpaceImport, `unknownPlayerIds` when that was the reason). Any
55
+ * other failure (network, timeout, a 5xx) is a plain Error. */
56
+ export declare class SpaceApiError extends Error {
57
+ readonly type: SpaceErrorType;
58
+ readonly status: number;
59
+ readonly details: Record<string, unknown>;
60
+ constructor(type: SpaceErrorType, status: number, details: Record<string, unknown>);
61
+ }
62
+ /** A 409 SpaceAlreadyExists on import: it already is a Space, and the
63
+ * platform's copy wins (§3.5.6). Use its membership; don't retry. */
64
+ export declare function isAlreadyASpace(e: unknown): e is SpaceApiError;
65
+ export interface SpaceClient {
66
+ /** null when there is no Space with this id of this game type. */
67
+ get(spaceId: string): Promise<PluginSpace | null>;
68
+ /** Create-only; idempotent for an identical body. */
69
+ import(spaceId: string, body: SpaceImport): Promise<SpaceImportResult>;
70
+ /** This game type's Spaces, invite codes and ACTIVE members, counted:
71
+ * what a backfill reads back to verify its real run against its dry run. */
72
+ counts(): Promise<SpaceCounts>;
73
+ }
74
+ export interface SpaceCounts {
75
+ spaces: number;
76
+ inviteCodes: number;
77
+ activeMembers: number;
78
+ }
79
+ export interface SpaceClientOptions {
80
+ /** The platform's base URL, e.g. http://platform:8080. */
81
+ platformUrl: string;
82
+ gameType: string;
83
+ /** The token this plugin registered with; sent as X-Plugin-Auth. */
84
+ authToken: string | undefined;
85
+ /** Per-request timeout. Default 5000. */
86
+ timeoutMs?: number;
87
+ fetchImpl?: typeof fetch;
88
+ }
89
+ export declare function createSpaceClient(options: SpaceClientOptions): SpaceClient;
90
+ /** The platform's ceiling on how stale a membership check may be. */
91
+ export declare const MAX_MEMBERSHIP_AGE_MS = 15000;
92
+ /** The writer is not (or no longer) an ACTIVE member of the Space, or
93
+ * there is no such Space. Refuse the write. */
94
+ export declare class NotASpaceMemberError extends Error {
95
+ readonly spaceId: string;
96
+ readonly playerId: string;
97
+ constructor(spaceId: string, playerId: string);
98
+ }
99
+ export interface MembershipGuardOptions {
100
+ /** How old a cached read may be. Default and ceiling: 15000 (§3.5.4). */
101
+ maxAgeMs?: number;
102
+ /** Clock, for tests. */
103
+ now?: () => number;
104
+ }
105
+ export interface MembershipGuard {
106
+ /** The Space, read at most maxAgeMs ago; null when there is none. */
107
+ space(spaceId: string): Promise<PluginSpace | null>;
108
+ /** Whether playerId is an ACTIVE member as of a read at most maxAgeMs old. */
109
+ isActiveMember(spaceId: string, playerId: string): Promise<boolean>;
110
+ /** Call before every durable write to the Space's shared state: returns
111
+ * the Space, or throws NotASpaceMemberError. A failed read throws too
112
+ * (fail closed), and is not cached. */
113
+ assertActiveMember(spaceId: string, playerId: string): Promise<PluginSpace>;
114
+ /** Drop the cached read for one Space, or for all. */
115
+ invalidate(spaceId?: string): void;
116
+ }
117
+ /** The plugin obligation of LIVE_CHANNELS_DESIGN.md §3.5.4 as one call:
118
+ * a cached Space read (never older than 15 s, concurrent misses share one
119
+ * request; a missing Space and a failed read are never cached) and the
120
+ * ACTIVE-member check on top of it. */
121
+ export declare function createMembershipGuard(client: Pick<SpaceClient, "get">, options?: MembershipGuardOptions): MembershipGuard;
122
+ /** An in-memory SpaceClient for a plugin's own tests: the platform's
123
+ * rules for import (create-only, idempotent for the same body, 409 for a
124
+ * different one or a taken code, one ACTIVE OWNER who is ownerId, known
125
+ * players only when `players` is given) and an ACTIVE-only read. Change
126
+ * membership with `setStatus` the way a player would on the platform. */
127
+ export declare class FakeSpaceClient implements SpaceClient {
128
+ private readonly options;
129
+ /** What the platform holds, every status included. */
130
+ readonly spaces: Map<string, {
131
+ space: Omit<PluginSpace, "members">;
132
+ inviteCode: string | null;
133
+ members: Map<string, SpaceImportMember>;
134
+ imported?: string;
135
+ }>;
136
+ /** Every call, as "get <id>" / "import <id>". */
137
+ readonly calls: string[];
138
+ /** Display names by playerId, used by reads. */
139
+ readonly names: Map<string, string>;
140
+ importEnabled: boolean;
141
+ constructor(options?: {
142
+ players?: Iterable<string>;
143
+ });
144
+ /** A Space as if created on the platform. */
145
+ add(space: {
146
+ id: string;
147
+ name?: string;
148
+ ownerId: string;
149
+ settings?: Record<string, unknown>;
150
+ inviteCode?: string | null;
151
+ }): void;
152
+ /** A join, leave or removal, as the platform's player routes make them. */
153
+ setStatus(spaceId: string, playerId: string, status: SpaceMemberStatus): void;
154
+ archive(spaceId: string): void;
155
+ get(spaceId: string): Promise<PluginSpace | null>;
156
+ import(spaceId: string, body: SpaceImport): Promise<SpaceImportResult>;
157
+ counts(): Promise<SpaceCounts>;
158
+ private view;
159
+ }
@@ -201,6 +201,23 @@ export declare namespace GameStatusOutput {
201
201
  */
202
202
  const validate: (obj: GameStatusOutput, path?: string) => __ValidationFailure[];
203
203
  }
204
+ /**
205
+ * A game type's Spaces: shared contexts above individual Games, whose
206
+ * membership the platform owns (LIVE_CHANNELS_DESIGN.md §3.5).
207
+ * @public
208
+ */
209
+ export interface SpaceCapability {
210
+ maxMembers: number | undefined;
211
+ label?: string | undefined;
212
+ settingsSchema?: __DocumentType | undefined;
213
+ gameConfig?: __DocumentType | undefined;
214
+ }
215
+ export declare namespace SpaceCapability {
216
+ /**
217
+ * @internal
218
+ */
219
+ const validate: (obj: SpaceCapability, path?: string) => __ValidationFailure[];
220
+ }
204
221
  /**
205
222
  * @public
206
223
  */
@@ -220,6 +237,12 @@ export interface GameTypeMetadata {
220
237
  hasSaveFiles?: boolean | undefined;
221
238
  hasSaveSummary?: boolean | undefined;
222
239
  hasSaveFileEditor?: boolean | undefined;
240
+ /**
241
+ * A game type's Spaces: shared contexts above individual Games, whose
242
+ * membership the platform owns (LIVE_CHANNELS_DESIGN.md §3.5).
243
+ * @public
244
+ */
245
+ spaces?: SpaceCapability | undefined;
223
246
  }
224
247
  export declare namespace GameTypeMetadata {
225
248
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anweather/game-plugin-ssdk",
3
3
  "description": "Generated GamePlugin RPC types/server (plugin-protocol/model/game-plugin.smithy) plus a hand-written Express bridge and default-handler wrapper (./runtime) -- see plugin-protocol/CLAUDE.md's \"Publishing for cross-repo plugins\".",
4
- "version": "1.16.0",
4
+ "version": "1.18.0",
5
5
  "scripts": {
6
6
  "build": "concurrently 'npm:build:cjs' 'npm:build:es' 'npm:build:types'",
7
7
  "build:cjs": "tsc -p tsconfig.cjs.json",
@@ -60,7 +60,8 @@
60
60
  "@smithy/util-defaults-mode-browser": "^4.0.20",
61
61
  "@smithy/util-defaults-mode-node": "^4.0.20",
62
62
  "@smithy/util-endpoints": "^3.0.6",
63
- "@smithy/util-utf8": "^4.0.0"
63
+ "@smithy/util-utf8": "^4.0.0",
64
+ "ws": "^8.18.0"
64
65
  },
65
66
  "devDependencies": {
66
67
  "@tsconfig/node18": "18.2.4",
@@ -71,7 +72,9 @@
71
72
  "rimraf": "^3.0.0",
72
73
  "typescript": "~5.8.3",
73
74
  "@types/node": "^18.19.69",
74
- "tsx": "^4.19.2"
75
+ "tsx": "^4.19.2",
76
+ "@types/ws": "^8.5.12",
77
+ "ajv": "^8.17.1"
75
78
  },
76
79
  "engines": {
77
80
  "node": ">=18.0.0"