castle-web-sdk 0.4.45 → 0.4.46
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/README.md +449 -53
- package/dist/agent.js +4 -4
- package/dist/avatar.d.ts +12 -0
- package/dist/avatar.js +23 -0
- package/dist/castle.d.ts +10 -1
- package/dist/castle.js +5 -0
- package/dist/commands.d.ts +223 -0
- package/dist/commands.js +16 -0
- package/dist/context.d.ts +6 -0
- package/dist/experiments.d.ts +15 -0
- package/dist/experiments.js +83 -0
- package/dist/hub.d.ts +85 -0
- package/dist/hub.js +107 -0
- package/dist/multiplayer.d.ts +86 -6
- package/dist/multiplayer.js +209 -8
- package/dist/save.d.ts +197 -0
- package/dist/save.js +525 -0
- package/dist/saveCoordinator.d.ts +39 -0
- package/dist/saveCoordinator.js +252 -0
- package/dist/server/hub.d.ts +169 -0
- package/dist/server/hub.js +125 -0
- package/dist/server/platformHandle.d.ts +49 -2
- package/dist/server/storage.d.ts +58 -4
- package/dist/server/storage.js +25 -5
- package/dist/server/wrapper.d.ts +89 -6
- package/dist/server/wrapper.js +395 -30
- package/dist/store.d.ts +3 -2
- package/dist/store.js +5 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +13 -1
- package/package.json +1 -1
package/dist/multiplayer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MultiplayerDestinationAudience, MultiplayerJoinHint, MultiplayerSoloReason } from "./commands";
|
|
1
|
+
import type { HubHandoffResult, MultiplayerDestinationAudience, MultiplayerJoinHint, MultiplayerSoloReason } from "./commands";
|
|
2
2
|
import { CastleError } from "./errors";
|
|
3
3
|
import type { Json } from "./types";
|
|
4
4
|
import { type MultiplayerPlayer, type MultiplayerCharacterDisclosure, type MultiplayerCharacterTurnStatus } from "./multiplayerProtocol";
|
|
@@ -11,6 +11,9 @@ export type MultiplayerJoinOptions = {
|
|
|
11
11
|
mode: "party";
|
|
12
12
|
} | {
|
|
13
13
|
mode: "public";
|
|
14
|
+
} | {
|
|
15
|
+
mode: "hub";
|
|
16
|
+
handoff: HubHandoffResult;
|
|
14
17
|
};
|
|
15
18
|
export interface MultiplayerDestinationOptions {
|
|
16
19
|
routeId: string;
|
|
@@ -79,7 +82,45 @@ export type MultiplayerWorldCharacterCommand = {
|
|
|
79
82
|
type: "record_event";
|
|
80
83
|
eventTypeId: string;
|
|
81
84
|
data?: Json;
|
|
85
|
+
/** Characters that saw it (at most 4); each may act on its own about the recorder. */
|
|
86
|
+
witnesses?: string[];
|
|
87
|
+
} | MultiplayerEvaluateBeatsCommand | MultiplayerCompleteScheduledBeatCommand;
|
|
88
|
+
export type MultiplayerStoryOpportunity = {
|
|
89
|
+
type: "approach" | "room_entry" | "world_action";
|
|
90
|
+
id: string;
|
|
82
91
|
};
|
|
92
|
+
export interface MultiplayerEvaluateBeatsCommand {
|
|
93
|
+
type: "evaluate_beats";
|
|
94
|
+
opportunityId: string;
|
|
95
|
+
opportunity: MultiplayerStoryOpportunity;
|
|
96
|
+
presentCharacterIds: string[];
|
|
97
|
+
}
|
|
98
|
+
export interface MultiplayerCompleteScheduledBeatCommand {
|
|
99
|
+
type: "complete_scheduled_beat";
|
|
100
|
+
deliveryId: string;
|
|
101
|
+
outcomeId: string;
|
|
102
|
+
}
|
|
103
|
+
export interface MultiplayerScheduledBeatSelection {
|
|
104
|
+
deliveryId: string;
|
|
105
|
+
beatId: string;
|
|
106
|
+
characterId: string;
|
|
107
|
+
openingText: string;
|
|
108
|
+
}
|
|
109
|
+
export interface MultiplayerScheduledBeatReason {
|
|
110
|
+
beatId: string;
|
|
111
|
+
status: "selected" | "expired" | "eligible" | "blocked";
|
|
112
|
+
reasonCodes: string[];
|
|
113
|
+
}
|
|
114
|
+
export interface MultiplayerEvaluateBeatsResult {
|
|
115
|
+
selection: MultiplayerScheduledBeatSelection | null;
|
|
116
|
+
reasons?: MultiplayerScheduledBeatReason[];
|
|
117
|
+
}
|
|
118
|
+
export interface MultiplayerCompleteScheduledBeatResult {
|
|
119
|
+
deliveryId: string;
|
|
120
|
+
outcomeId: string;
|
|
121
|
+
completed: true;
|
|
122
|
+
}
|
|
123
|
+
export type MultiplayerWorldCharacterCommandResultValue<Command extends MultiplayerWorldCharacterCommand> = Command extends MultiplayerEvaluateBeatsCommand ? MultiplayerEvaluateBeatsResult : Command extends MultiplayerCompleteScheduledBeatCommand ? MultiplayerCompleteScheduledBeatResult : Json;
|
|
83
124
|
export type MultiplayerWorldCharacterQuery = {
|
|
84
125
|
type: "inspect";
|
|
85
126
|
} | {
|
|
@@ -90,7 +131,33 @@ export type MultiplayerWorldCharacterQuery = {
|
|
|
90
131
|
characterId?: string;
|
|
91
132
|
limit?: number;
|
|
92
133
|
cursor?: string;
|
|
134
|
+
}
|
|
135
|
+
/** Autonomous-action notices after a cursor (world-visible). */
|
|
136
|
+
| {
|
|
137
|
+
type: "actions";
|
|
138
|
+
after?: string;
|
|
93
139
|
};
|
|
140
|
+
/** One autonomous character decision in a shared world. */
|
|
141
|
+
export interface MultiplayerCharacterActionNotice {
|
|
142
|
+
characterId: string;
|
|
143
|
+
/** The participant the character acted about: the one whose deed it witnessed. */
|
|
144
|
+
participantId: string;
|
|
145
|
+
trigger: {
|
|
146
|
+
type: "witnessed";
|
|
147
|
+
id: string;
|
|
148
|
+
};
|
|
149
|
+
outcome: "acted" | "declined" | "failed";
|
|
150
|
+
actionId?: string;
|
|
151
|
+
variantId?: string;
|
|
152
|
+
eventOccurrenceId?: string;
|
|
153
|
+
probability?: number;
|
|
154
|
+
/** Feed cursor; pass the last one as `after` to read only newer notices. */
|
|
155
|
+
sequence: string;
|
|
156
|
+
}
|
|
157
|
+
export interface MultiplayerWorldCharacterActionsResult {
|
|
158
|
+
notices: MultiplayerCharacterActionNotice[];
|
|
159
|
+
after: string;
|
|
160
|
+
}
|
|
94
161
|
/** Authenticated, participant-scoped projection of a shared character world. */
|
|
95
162
|
export interface MultiplayerWorldCharacterInspectResult<Gameplay = unknown, SharedState = unknown, PrivateState = unknown, Source = unknown> {
|
|
96
163
|
/** Durable identity used by gameplay holders and promise debtors. */
|
|
@@ -106,11 +173,11 @@ export interface MultiplayerWorldCharacterInspectResult<Gameplay = unknown, Shar
|
|
|
106
173
|
}>;
|
|
107
174
|
sources: Source[];
|
|
108
175
|
}
|
|
109
|
-
export interface MultiplayerWorldCharacterCommandResult {
|
|
176
|
+
export interface MultiplayerWorldCharacterCommandResult<Result = unknown> {
|
|
110
177
|
operationId: string;
|
|
111
178
|
status: "applied" | "ineligible";
|
|
112
179
|
replayed: boolean;
|
|
113
|
-
result:
|
|
180
|
+
result: Result;
|
|
114
181
|
worldRevision: string;
|
|
115
182
|
}
|
|
116
183
|
export interface MultiplayerConnection {
|
|
@@ -123,17 +190,24 @@ export interface MultiplayerConnection {
|
|
|
123
190
|
* `private_deck` and `no_server_bundle` are the creator's to fix.
|
|
124
191
|
*/
|
|
125
192
|
readonly soloReason: MultiplayerSoloReason | null;
|
|
193
|
+
/**
|
|
194
|
+
* Whether the host brokers shared characters into this session (its hello
|
|
195
|
+
* advertised them): a shared-character world, or a hub place's layer with
|
|
196
|
+
* place characters. The character calls below refuse while this is false.
|
|
197
|
+
* Read it when needed: a reconnect can land on a host that says otherwise.
|
|
198
|
+
*/
|
|
199
|
+
readonly sharedCharacters: boolean;
|
|
126
200
|
/** Sends byte payloads as binary frames and all other payloads as JSON. */
|
|
127
201
|
send(data: unknown): void;
|
|
128
202
|
/** Requests one billed character turn through this authenticated world socket. */
|
|
129
203
|
requestCharacterTurn(request: MultiplayerCharacterTurnRequest): Promise<MultiplayerCharacterTurnAdmission>;
|
|
130
204
|
/** Recovers the durable status of a prior operation after reconnecting. */
|
|
131
205
|
getCharacterTurnStatus(operationId: string): Promise<MultiplayerCharacterTurnStatusResult>;
|
|
132
|
-
requestCharacterCommand(request: {
|
|
206
|
+
requestCharacterCommand<Command extends MultiplayerWorldCharacterCommand>(request: {
|
|
133
207
|
requestId: string;
|
|
134
208
|
operationId: string;
|
|
135
|
-
command:
|
|
136
|
-
}): Promise<MultiplayerWorldCharacterCommandResult
|
|
209
|
+
command: Command;
|
|
210
|
+
}): Promise<MultiplayerWorldCharacterCommandResult<MultiplayerWorldCharacterCommandResultValue<Command>>>;
|
|
137
211
|
readCharacters(request: {
|
|
138
212
|
requestId: string;
|
|
139
213
|
query: {
|
|
@@ -153,6 +227,12 @@ export interface MultiplayerConnection {
|
|
|
153
227
|
leave(): void;
|
|
154
228
|
}
|
|
155
229
|
export interface CastleMultiplayerApi {
|
|
230
|
+
/**
|
|
231
|
+
* True on an SDK whose session server can address a hub place's shared
|
|
232
|
+
* characters from a layer (`session.characters` without `session.world`).
|
|
233
|
+
* Absent on older SDKs, so a kit can tell before joining.
|
|
234
|
+
*/
|
|
235
|
+
readonly placeCharacters: true;
|
|
156
236
|
join(options: MultiplayerJoinOptions): Promise<MultiplayerConnection>;
|
|
157
237
|
joinHint(): Promise<MultiplayerJoinHint | null>;
|
|
158
238
|
joinDestination(options: MultiplayerDestinationOptions): Promise<MultiplayerDestinationJoinResult>;
|
package/dist/multiplayer.js
CHANGED
|
@@ -18,7 +18,12 @@ let nextCharacterStatusRequestId = 1;
|
|
|
18
18
|
let activeConnection = null;
|
|
19
19
|
let latestJoinAttempt = 0;
|
|
20
20
|
let pendingDestinationJoin = null;
|
|
21
|
-
export const Multiplayer = {
|
|
21
|
+
export const Multiplayer = {
|
|
22
|
+
placeCharacters: true,
|
|
23
|
+
join,
|
|
24
|
+
joinHint,
|
|
25
|
+
joinDestination,
|
|
26
|
+
};
|
|
22
27
|
async function joinHint() {
|
|
23
28
|
try {
|
|
24
29
|
return await hostRequest("multiplayer.getJoinHint", {});
|
|
@@ -30,18 +35,71 @@ async function joinHint() {
|
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
37
|
async function join(options) {
|
|
33
|
-
const
|
|
38
|
+
const hub = hubJoin(options);
|
|
39
|
+
const request = hub
|
|
40
|
+
? { command: "hub", nonce: hub.nonce }
|
|
41
|
+
: { command: "multiplayer.getSession", params: joinParams(options) };
|
|
34
42
|
latestJoinAttempt += 1;
|
|
35
43
|
cancelPendingDestinationJoin();
|
|
36
44
|
activeConnection?.leave();
|
|
37
|
-
const connection = new MultiplayerConnectionImpl(
|
|
45
|
+
const connection = new MultiplayerConnectionImpl(request, () => {
|
|
38
46
|
if (activeConnection === connection)
|
|
39
47
|
activeConnection = null;
|
|
40
48
|
});
|
|
41
49
|
activeConnection = connection;
|
|
42
|
-
await connection.start();
|
|
50
|
+
await connection.start(hub ?? undefined);
|
|
51
|
+
// The host moves its place (and every place-scoped command with it) when the
|
|
52
|
+
// trip commits; the deck gets the connection once it has. A trip that did not
|
|
53
|
+
// commit leaves the player where they were, so its connection is closed.
|
|
54
|
+
if (!(await connection.hubCommitted())) {
|
|
55
|
+
connection.leave();
|
|
56
|
+
throw multiplayerError("MULTIPLAYER_UNAVAILABLE", "The hub trip could not be completed; the player is still where they were.", "Multiplayer.join");
|
|
57
|
+
}
|
|
58
|
+
connection.handedOver();
|
|
43
59
|
return connection;
|
|
44
60
|
}
|
|
61
|
+
// A hub handoff is joined as it was acquired; it cannot be acquired again.
|
|
62
|
+
// The host answers `unavailable` while ghost is busy with this player; the
|
|
63
|
+
// trip stays pending there, so asking again is safe.
|
|
64
|
+
const HUB_COMMIT_RETRY_MS = [1_000, 2_000, 4_000, 8_000];
|
|
65
|
+
async function commitHubTrip(nonce, sessionId) {
|
|
66
|
+
for (let attempt = 0;; attempt++) {
|
|
67
|
+
let status;
|
|
68
|
+
try {
|
|
69
|
+
status = (await hostRequest("hub.commit", { nonce })).status;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
status = error instanceof CastleError ? error.code : "unavailable";
|
|
73
|
+
}
|
|
74
|
+
if (status === "committed")
|
|
75
|
+
return true;
|
|
76
|
+
const delay = HUB_COMMIT_RETRY_MS[attempt];
|
|
77
|
+
if ((status !== "unavailable" && status !== "CASTLE_HOST_UNAVAILABLE") || delay === undefined) {
|
|
78
|
+
console.warn(`Castle hub trip to ${sessionId} was not committed: ${status}`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
await pause(delay);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function hubJoin(options) {
|
|
85
|
+
if (!options || typeof options !== "object" || options.mode !== "hub")
|
|
86
|
+
return null;
|
|
87
|
+
const handoff = options.handoff;
|
|
88
|
+
if (!handoff ||
|
|
89
|
+
handoff.status !== "ok" ||
|
|
90
|
+
typeof handoff.url !== "string" ||
|
|
91
|
+
typeof handoff.nonce !== "string" ||
|
|
92
|
+
typeof handoff.sessionId !== "string") {
|
|
93
|
+
throw multiplayerError("INVALID_ARGUMENT", "Multiplayer.join({ mode: \"hub\" }) requires an ok result from Hub.go or Hub.back.", "Multiplayer.join");
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
status: "ok",
|
|
97
|
+
url: handoff.url,
|
|
98
|
+
nonce: handoff.nonce,
|
|
99
|
+
sessionId: handoff.sessionId,
|
|
100
|
+
soloReason: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
45
103
|
async function joinDestination(options) {
|
|
46
104
|
const params = destinationParams(options);
|
|
47
105
|
const attempt = ++latestJoinAttempt;
|
|
@@ -112,6 +170,8 @@ async function joinDestination(options) {
|
|
|
112
170
|
activeConnection = connection;
|
|
113
171
|
return { status: "joined", connection, audience: acquired.audience };
|
|
114
172
|
}
|
|
173
|
+
// How many messages are kept for a deck that has not attached a listener yet.
|
|
174
|
+
const EARLY_MESSAGES_MAX = 512;
|
|
115
175
|
class MultiplayerConnectionImpl {
|
|
116
176
|
request;
|
|
117
177
|
onClosed;
|
|
@@ -129,10 +189,22 @@ class MultiplayerConnectionImpl {
|
|
|
129
189
|
keepalive = null;
|
|
130
190
|
reAcquisitionUsed = false;
|
|
131
191
|
recovering = false;
|
|
192
|
+
hubSettled = false;
|
|
193
|
+
hubCommit = Promise.resolve(true);
|
|
132
194
|
roster = null;
|
|
133
195
|
sharedCharactersSupported = false;
|
|
134
196
|
pendingCharacterRequests = new Map();
|
|
135
197
|
messages = new Set();
|
|
198
|
+
// Messages that arrived before the deck had a listener, on a hub join only.
|
|
199
|
+
// A hub join resolves some tasks after the socket's hello (it first commits
|
|
200
|
+
// with the host), and a place greets a player with a snapshot in that gap:
|
|
201
|
+
// with nobody listening it was dropped, and the deck never saw the state it
|
|
202
|
+
// joined into. They are kept, in order, for a listener attached as soon as
|
|
203
|
+
// the deck is handed the connection (`handedOver`); a deck that attaches one
|
|
204
|
+
// later gets only what arrives from then on. Null on every other join, which
|
|
205
|
+
// resolves in the hello's own task: nothing is kept there and delivery is
|
|
206
|
+
// exactly what it always was.
|
|
207
|
+
early = null;
|
|
136
208
|
rosters = new Set();
|
|
137
209
|
states = new Set();
|
|
138
210
|
voiceImpl = new MultiplayerVoiceImpl({
|
|
@@ -160,6 +232,9 @@ class MultiplayerConnectionImpl {
|
|
|
160
232
|
get soloReason() {
|
|
161
233
|
return this.currentSoloReason;
|
|
162
234
|
}
|
|
235
|
+
get sharedCharacters() {
|
|
236
|
+
return this.sharedCharactersSupported;
|
|
237
|
+
}
|
|
163
238
|
get voice() {
|
|
164
239
|
return this.voiceImpl;
|
|
165
240
|
}
|
|
@@ -168,6 +243,7 @@ class MultiplayerConnectionImpl {
|
|
|
168
243
|
await this.acquire(initial);
|
|
169
244
|
}
|
|
170
245
|
catch (error) {
|
|
246
|
+
this.settleHub("cancel");
|
|
171
247
|
const castleError = asCastleError(error);
|
|
172
248
|
if (this.currentState !== "closed") {
|
|
173
249
|
this.finish({ type: "error", error: castleError });
|
|
@@ -249,8 +325,43 @@ class MultiplayerConnectionImpl {
|
|
|
249
325
|
}
|
|
250
326
|
onMessage(callback) {
|
|
251
327
|
this.messages.add(callback);
|
|
328
|
+
const early = this.early;
|
|
329
|
+
if (early) {
|
|
330
|
+
// The first listener takes what arrived before it; later ones get only
|
|
331
|
+
// what arrives from now on, as before.
|
|
332
|
+
this.early = null;
|
|
333
|
+
for (const data of early) {
|
|
334
|
+
// A listener that leaves part-way hears no more.
|
|
335
|
+
if (this.currentState === "closed")
|
|
336
|
+
break;
|
|
337
|
+
callListener(callback, data);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
252
340
|
return () => this.messages.delete(callback);
|
|
253
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* The deck is being handed this connection. Whatever arrived before that is
|
|
344
|
+
* kept through the deck's own next step (the code after its `await`), where
|
|
345
|
+
* a kit attaches its listener; after that, nothing more is kept.
|
|
346
|
+
*/
|
|
347
|
+
handedOver() {
|
|
348
|
+
if (!this.early)
|
|
349
|
+
return;
|
|
350
|
+
setTimeout(() => {
|
|
351
|
+
if (this.messages.size === 0)
|
|
352
|
+
this.early = null;
|
|
353
|
+
}, 0);
|
|
354
|
+
}
|
|
355
|
+
deliver(data) {
|
|
356
|
+
if (this.early && this.messages.size === 0) {
|
|
357
|
+
// Bounded: a deck that never listens does not grow this forever.
|
|
358
|
+
if (this.early.length < EARLY_MESSAGES_MAX)
|
|
359
|
+
this.early.push(data);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
for (const callback of this.messages)
|
|
363
|
+
callListener(callback, data);
|
|
364
|
+
}
|
|
254
365
|
onRoster(callback) {
|
|
255
366
|
this.rosters.add(callback);
|
|
256
367
|
if (this.roster)
|
|
@@ -265,8 +376,27 @@ class MultiplayerConnectionImpl {
|
|
|
265
376
|
leave() {
|
|
266
377
|
if (this.currentState === "closed")
|
|
267
378
|
return;
|
|
379
|
+
this.settleHub("cancel");
|
|
268
380
|
this.finish({ type: "left" });
|
|
269
381
|
}
|
|
382
|
+
// A hub trip is the host's to commit or cancel, once: committed when the
|
|
383
|
+
// destination first says hello, cancelled if the join ends before that. The
|
|
384
|
+
// acquisition is named by its nonce, which no other acquisition shares.
|
|
385
|
+
settleHub(outcome) {
|
|
386
|
+
if (this.request.command !== "hub" || this.hubSettled)
|
|
387
|
+
return;
|
|
388
|
+
this.hubSettled = true;
|
|
389
|
+
const nonce = this.request.nonce;
|
|
390
|
+
if (outcome === "cancel") {
|
|
391
|
+
hostRequest("hub.cancel", { nonce }).catch(() => { });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
this.early = [];
|
|
395
|
+
this.hubCommit = commitHubTrip(nonce, this.currentSessionId);
|
|
396
|
+
}
|
|
397
|
+
hubCommitted() {
|
|
398
|
+
return this.hubCommit;
|
|
399
|
+
}
|
|
270
400
|
matchesDestination(params) {
|
|
271
401
|
return (this.currentState !== "closed" &&
|
|
272
402
|
this.request.command === "multiplayer.joinDestination" &&
|
|
@@ -329,6 +459,8 @@ class MultiplayerConnectionImpl {
|
|
|
329
459
|
}
|
|
330
460
|
connectSolo(result) {
|
|
331
461
|
this.solo = true;
|
|
462
|
+
// A solo session has no host, so no characters, whatever the last socket's hello offered.
|
|
463
|
+
this.sharedCharactersSupported = false;
|
|
332
464
|
this.currentSoloReason = result.soloReason ?? "unavailable";
|
|
333
465
|
this.currentPlayerId = "solo";
|
|
334
466
|
this.currentSessionId = result.sessionId ?? "solo";
|
|
@@ -422,6 +554,7 @@ class MultiplayerConnectionImpl {
|
|
|
422
554
|
// is backgrounded many times in a play, and each of those is its own outage;
|
|
423
555
|
// without this the second one finds the budget spent and closes for good.
|
|
424
556
|
this.reAcquisitionUsed = false;
|
|
557
|
+
this.settleHub("commit");
|
|
425
558
|
this.transition("connected");
|
|
426
559
|
this.startKeepalive();
|
|
427
560
|
void this.voiceImpl.attach();
|
|
@@ -432,8 +565,7 @@ class MultiplayerConnectionImpl {
|
|
|
432
565
|
this.acceptRoster(frame);
|
|
433
566
|
break;
|
|
434
567
|
case "m":
|
|
435
|
-
|
|
436
|
-
callListener(callback, frame.d);
|
|
568
|
+
this.deliver(frame.d);
|
|
437
569
|
break;
|
|
438
570
|
case "worldCharacterResult":
|
|
439
571
|
this.acceptWorldCharacterResult(frame);
|
|
@@ -458,8 +590,7 @@ class MultiplayerConnectionImpl {
|
|
|
458
590
|
if (bytes.byteLength < 1 || bytes[0] !== BINARY_MESSAGE_TAG)
|
|
459
591
|
return;
|
|
460
592
|
const payload = new Uint8Array(buffer, 1);
|
|
461
|
-
|
|
462
|
-
callListener(callback, payload);
|
|
593
|
+
this.deliver(payload);
|
|
463
594
|
}
|
|
464
595
|
acceptRoster(frame) {
|
|
465
596
|
const players = frame.players.slice();
|
|
@@ -569,6 +700,7 @@ class MultiplayerConnectionImpl {
|
|
|
569
700
|
this.socketAttempt += 1;
|
|
570
701
|
closeSocket(socket, 1000, "session closed");
|
|
571
702
|
this.currentState = "closed";
|
|
703
|
+
this.early = null;
|
|
572
704
|
this.voiceImpl.dispose();
|
|
573
705
|
for (const callback of this.states)
|
|
574
706
|
callStateListener(callback, "closed", metadata);
|
|
@@ -685,6 +817,11 @@ function cancelPendingDestinationJoin() {
|
|
|
685
817
|
pending?.connection?.leave();
|
|
686
818
|
}
|
|
687
819
|
async function requestAcquisition(request) {
|
|
820
|
+
if (request.command === "hub") {
|
|
821
|
+
// The nonce was single-use and the trip is the host's; a hub session that
|
|
822
|
+
// cannot be reconnected to is left by going back through the door.
|
|
823
|
+
throw multiplayerError("MULTIPLAYER_CONNECTION_LOST", "A hub session cannot be acquired again. Go back through the door.");
|
|
824
|
+
}
|
|
688
825
|
if (request.command === "multiplayer.getSession") {
|
|
689
826
|
return hostRequest(request.command, request.params);
|
|
690
827
|
}
|
|
@@ -778,6 +915,42 @@ function normalizeWorldCharacterCommandRequest(request) {
|
|
|
778
915
|
type: "record_event",
|
|
779
916
|
eventTypeId: boundedCharacterText(command.eventTypeId, 128, "command.eventTypeId", WORLD_CHARACTER_ID_MAX_CHARACTERS),
|
|
780
917
|
...(command.data === undefined ? {} : { data: normalizeJson(command.data, "command.data") }),
|
|
918
|
+
...(command.witnesses === undefined ? {} : { witnesses: normalizeWitnesses(command.witnesses) }),
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
if (command.type === "evaluate_beats") {
|
|
923
|
+
const opportunity = command.opportunity;
|
|
924
|
+
if (!opportunity || typeof opportunity !== "object") {
|
|
925
|
+
throw characterError("INVALID_ARGUMENT", "command.opportunity must be an opportunity object.");
|
|
926
|
+
}
|
|
927
|
+
if (opportunity.type !== "approach" &&
|
|
928
|
+
opportunity.type !== "room_entry" &&
|
|
929
|
+
opportunity.type !== "world_action") {
|
|
930
|
+
throw characterError("INVALID_ARGUMENT", "command.opportunity.type is invalid.");
|
|
931
|
+
}
|
|
932
|
+
return {
|
|
933
|
+
requestId,
|
|
934
|
+
operationId,
|
|
935
|
+
command: {
|
|
936
|
+
type: "evaluate_beats",
|
|
937
|
+
opportunityId: boundedCharacterSafeKey(command.opportunityId, "command.opportunityId"),
|
|
938
|
+
opportunity: {
|
|
939
|
+
type: opportunity.type,
|
|
940
|
+
id: boundedCharacterSafeKey(opportunity.id, "command.opportunity.id"),
|
|
941
|
+
},
|
|
942
|
+
presentCharacterIds: boundedCharacterSafeKeys(command.presentCharacterIds, 16, "command.presentCharacterIds"),
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
if (command.type === "complete_scheduled_beat") {
|
|
947
|
+
return {
|
|
948
|
+
requestId,
|
|
949
|
+
operationId,
|
|
950
|
+
command: {
|
|
951
|
+
type: "complete_scheduled_beat",
|
|
952
|
+
deliveryId: boundedCharacterSafeKey(command.deliveryId, "command.deliveryId"),
|
|
953
|
+
outcomeId: boundedCharacterSafeKey(command.outcomeId, "command.outcomeId"),
|
|
781
954
|
},
|
|
782
955
|
};
|
|
783
956
|
}
|
|
@@ -808,8 +981,20 @@ function normalizeWorldCharacterReadRequest(request) {
|
|
|
808
981
|
},
|
|
809
982
|
};
|
|
810
983
|
}
|
|
984
|
+
if (query.type === "actions") {
|
|
985
|
+
if (query.after !== undefined && (typeof query.after !== "string" || !/^\d{1,18}$/.test(query.after))) {
|
|
986
|
+
throw characterError("INVALID_ARGUMENT", "Character actions cursor must be a sequence string.");
|
|
987
|
+
}
|
|
988
|
+
return { requestId, query: { type: "actions", ...(query.after === undefined ? {} : { after: query.after }) } };
|
|
989
|
+
}
|
|
811
990
|
throw characterError("INVALID_ARGUMENT", "Unknown shared character query type.");
|
|
812
991
|
}
|
|
992
|
+
function normalizeWitnesses(value) {
|
|
993
|
+
if (!Array.isArray(value) || value.length > 4) {
|
|
994
|
+
throw characterError("INVALID_ARGUMENT", "command.witnesses must be at most 4 character ids.");
|
|
995
|
+
}
|
|
996
|
+
return value.map((item, index) => boundedCharacterText(item, 128, `command.witnesses[${index}]`, WORLD_CHARACTER_ID_MAX_CHARACTERS));
|
|
997
|
+
}
|
|
813
998
|
function normalizeJson(value, field) {
|
|
814
999
|
try {
|
|
815
1000
|
const text = JSON.stringify(value);
|
|
@@ -831,6 +1016,22 @@ function boundedCharacterIds(value, maxItems, field, maxCharacters = 128) {
|
|
|
831
1016
|
}
|
|
832
1017
|
return ids;
|
|
833
1018
|
}
|
|
1019
|
+
function boundedCharacterSafeKeys(value, maxItems, field) {
|
|
1020
|
+
if (!Array.isArray(value) || value.length > maxItems) {
|
|
1021
|
+
throw characterError("INVALID_ARGUMENT", `${field} may contain at most ${maxItems} ids.`);
|
|
1022
|
+
}
|
|
1023
|
+
const ids = value.map((item) => boundedCharacterSafeKey(item, field));
|
|
1024
|
+
if (new Set(ids).size !== ids.length) {
|
|
1025
|
+
throw characterError("INVALID_ARGUMENT", `${field} may not contain duplicate ids.`);
|
|
1026
|
+
}
|
|
1027
|
+
return ids;
|
|
1028
|
+
}
|
|
1029
|
+
function boundedCharacterSafeKey(value, field) {
|
|
1030
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(value)) {
|
|
1031
|
+
throw characterError("INVALID_ARGUMENT", `${field} must be a safe key of at most 64 characters.`);
|
|
1032
|
+
}
|
|
1033
|
+
return value;
|
|
1034
|
+
}
|
|
834
1035
|
function boundedCharacterText(value, maxBytes, field, maxCharacters = maxBytes) {
|
|
835
1036
|
if (typeof value !== "string" ||
|
|
836
1037
|
value.length === 0 ||
|
package/dist/save.d.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { CastleError } from "./errors";
|
|
2
|
+
import type { Json } from "./types";
|
|
3
|
+
export type SaveScope = "deck" | "user" | "private";
|
|
4
|
+
export interface SaveTarget {
|
|
5
|
+
scope: SaveScope;
|
|
6
|
+
/** A user target's player. Omitted means the current player. */
|
|
7
|
+
subject?: string;
|
|
8
|
+
prefix: string;
|
|
9
|
+
}
|
|
10
|
+
/** Opaque. A reset of the deck's storage makes every older revision stale. */
|
|
11
|
+
export type SaveRevision = string;
|
|
12
|
+
/** Opaque proof of the latest claim on a target. */
|
|
13
|
+
export type SaveWriter = string;
|
|
14
|
+
export type SaveRows = Record<string, Json>;
|
|
15
|
+
export type SaveSelection = {
|
|
16
|
+
prefix: string;
|
|
17
|
+
} | {
|
|
18
|
+
keys: string[];
|
|
19
|
+
};
|
|
20
|
+
export interface SaveReadTarget {
|
|
21
|
+
target: SaveTarget;
|
|
22
|
+
/** Omitted reads the whole target. */
|
|
23
|
+
select?: SaveSelection;
|
|
24
|
+
}
|
|
25
|
+
export interface SaveSnapshot {
|
|
26
|
+
access: string;
|
|
27
|
+
targets: {
|
|
28
|
+
target: SaveTarget;
|
|
29
|
+
/** Null means the target has never been initialized. */
|
|
30
|
+
revision: SaveRevision | null;
|
|
31
|
+
rows: SaveRows;
|
|
32
|
+
/** True when the rows cover the whole target, not a selection of it. */
|
|
33
|
+
complete: boolean;
|
|
34
|
+
}[];
|
|
35
|
+
}
|
|
36
|
+
export interface SaveReceipt {
|
|
37
|
+
operationId: string;
|
|
38
|
+
access: string;
|
|
39
|
+
revisions: {
|
|
40
|
+
target: SaveTarget;
|
|
41
|
+
revision: SaveRevision;
|
|
42
|
+
}[];
|
|
43
|
+
writer?: SaveWriter;
|
|
44
|
+
}
|
|
45
|
+
export interface SaveCheck {
|
|
46
|
+
target: SaveTarget;
|
|
47
|
+
expectedRevision?: SaveRevision;
|
|
48
|
+
writer?: SaveWriter;
|
|
49
|
+
}
|
|
50
|
+
export interface SaveChange {
|
|
51
|
+
target: SaveTarget;
|
|
52
|
+
/** Null creates a target that has never been initialized. */
|
|
53
|
+
expectedRevision: SaveRevision | null;
|
|
54
|
+
writer?: SaveWriter;
|
|
55
|
+
put?: SaveRows;
|
|
56
|
+
remove?: string[];
|
|
57
|
+
/** Replaces every row of the target. Cannot be combined with put or remove. */
|
|
58
|
+
replace?: SaveRows;
|
|
59
|
+
}
|
|
60
|
+
export interface SaveTransactionInput {
|
|
61
|
+
operationId: string;
|
|
62
|
+
expectedAccess: string;
|
|
63
|
+
checks?: SaveCheck[];
|
|
64
|
+
changes: SaveChange[];
|
|
65
|
+
}
|
|
66
|
+
export interface SaveInitial {
|
|
67
|
+
target: SaveTarget;
|
|
68
|
+
rows: SaveRows;
|
|
69
|
+
}
|
|
70
|
+
export interface SaveClaimInput {
|
|
71
|
+
operationId: string;
|
|
72
|
+
expectedAccess?: string;
|
|
73
|
+
target: SaveTarget;
|
|
74
|
+
initial?: SaveInitial[];
|
|
75
|
+
/** Omitted loads the claimed and initial targets. */
|
|
76
|
+
load?: SaveReadTarget[];
|
|
77
|
+
}
|
|
78
|
+
export interface SaveInitializeInput {
|
|
79
|
+
operationId: string;
|
|
80
|
+
expectedAccess?: string;
|
|
81
|
+
initial: SaveInitial[];
|
|
82
|
+
/** Omitted loads the initial targets. */
|
|
83
|
+
load?: SaveReadTarget[];
|
|
84
|
+
}
|
|
85
|
+
export interface SaveOpened {
|
|
86
|
+
receipt: SaveReceipt;
|
|
87
|
+
snapshot: SaveSnapshot;
|
|
88
|
+
}
|
|
89
|
+
export interface SaveClaimed extends SaveOpened {
|
|
90
|
+
writer: SaveWriter;
|
|
91
|
+
}
|
|
92
|
+
export type SaveOperation = {
|
|
93
|
+
state: "committed";
|
|
94
|
+
receipt: SaveReceipt;
|
|
95
|
+
} | {
|
|
96
|
+
state: "unknown";
|
|
97
|
+
};
|
|
98
|
+
export type SaveVerb = "snapshot" | "transaction" | "claim" | "initialize" | "operation";
|
|
99
|
+
export interface SaveApi {
|
|
100
|
+
snapshot(input: {
|
|
101
|
+
targets: SaveReadTarget[];
|
|
102
|
+
}): Promise<SaveSnapshot>;
|
|
103
|
+
transaction(input: SaveTransactionInput): Promise<SaveReceipt>;
|
|
104
|
+
claim(input: SaveClaimInput): Promise<SaveClaimed>;
|
|
105
|
+
initialize(input: SaveInitializeInput): Promise<SaveOpened>;
|
|
106
|
+
operation(operationId: string): Promise<SaveOperation>;
|
|
107
|
+
}
|
|
108
|
+
export interface SaveBucketRead {
|
|
109
|
+
prefix: string;
|
|
110
|
+
select?: SaveSelection;
|
|
111
|
+
}
|
|
112
|
+
export interface SaveBucketApi {
|
|
113
|
+
/** This bucket's target for a prefix, for the root's multi-bucket calls. */
|
|
114
|
+
target(prefix: string): SaveTarget;
|
|
115
|
+
snapshot(input: SaveBucketRead): Promise<SaveSnapshot>;
|
|
116
|
+
transaction(input: {
|
|
117
|
+
operationId: string;
|
|
118
|
+
expectedAccess: string;
|
|
119
|
+
checks?: (Omit<SaveCheck, "target"> & {
|
|
120
|
+
prefix: string;
|
|
121
|
+
})[];
|
|
122
|
+
changes: (Omit<SaveChange, "target"> & {
|
|
123
|
+
prefix: string;
|
|
124
|
+
})[];
|
|
125
|
+
}): Promise<SaveReceipt>;
|
|
126
|
+
claim(input: {
|
|
127
|
+
operationId: string;
|
|
128
|
+
expectedAccess?: string;
|
|
129
|
+
prefix: string;
|
|
130
|
+
initial?: {
|
|
131
|
+
prefix: string;
|
|
132
|
+
rows: SaveRows;
|
|
133
|
+
}[];
|
|
134
|
+
load?: SaveBucketRead[];
|
|
135
|
+
}): Promise<SaveClaimed>;
|
|
136
|
+
initialize(input: {
|
|
137
|
+
operationId: string;
|
|
138
|
+
expectedAccess?: string;
|
|
139
|
+
initial: {
|
|
140
|
+
prefix: string;
|
|
141
|
+
rows: SaveRows;
|
|
142
|
+
}[];
|
|
143
|
+
load?: SaveBucketRead[];
|
|
144
|
+
}): Promise<SaveOpened>;
|
|
145
|
+
operation(operationId: string): Promise<SaveOperation>;
|
|
146
|
+
}
|
|
147
|
+
/** What a failed save says about itself, as the platform reports it. */
|
|
148
|
+
export interface SaveErrorEnvelope {
|
|
149
|
+
code: string;
|
|
150
|
+
message: string;
|
|
151
|
+
operationId?: string;
|
|
152
|
+
retryable: boolean;
|
|
153
|
+
/** "unknown" means the request may still have committed. */
|
|
154
|
+
committed: boolean | "unknown";
|
|
155
|
+
/** The stored receipt, when the operation committed earlier. */
|
|
156
|
+
receipt?: SaveReceipt;
|
|
157
|
+
retryAfterMs?: number;
|
|
158
|
+
target?: SaveTarget;
|
|
159
|
+
[detail: string]: unknown;
|
|
160
|
+
}
|
|
161
|
+
export declare class SaveError extends CastleError {
|
|
162
|
+
readonly operationId?: string;
|
|
163
|
+
readonly retryable: boolean;
|
|
164
|
+
readonly committed: boolean | "unknown";
|
|
165
|
+
readonly receipt?: SaveReceipt;
|
|
166
|
+
readonly retryAfterMs?: number;
|
|
167
|
+
readonly target?: SaveTarget;
|
|
168
|
+
constructor(envelope: SaveErrorEnvelope);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Reads the envelope out of whatever a host or platform threw. A failure that
|
|
172
|
+
* does not say whether it committed is treated as one that might have.
|
|
173
|
+
*/
|
|
174
|
+
export declare function asSaveError(error: unknown, operationId?: string): SaveError;
|
|
175
|
+
/**
|
|
176
|
+
* A session server has no current player, so it names a user target's player
|
|
177
|
+
* and cannot reach private storage.
|
|
178
|
+
*/
|
|
179
|
+
export declare function validateSaveTarget(target: SaveTarget, server?: boolean): SaveTarget;
|
|
180
|
+
export declare function saveTargetKey(target: SaveTarget): string;
|
|
181
|
+
/**
|
|
182
|
+
* Finds the caller's own target for one the platform names. The platform
|
|
183
|
+
* fills in the current player as a user target's subject, so a target the
|
|
184
|
+
* caller left without a subject matches any subject.
|
|
185
|
+
*/
|
|
186
|
+
export declare function matchSaveTarget(target: SaveTarget, candidates: readonly SaveTarget[]): SaveTarget;
|
|
187
|
+
/** Validates and copies a request; the result is what goes on the wire. */
|
|
188
|
+
export declare function encodeSaveInput(verb: SaveVerb, input: Record<string, unknown>, server?: boolean): Record<string, unknown>;
|
|
189
|
+
/** Sends one encoded request; the transport behind it is the caller's. */
|
|
190
|
+
export type SaveCall = (verb: SaveVerb, input: Record<string, unknown>) => Promise<unknown>;
|
|
191
|
+
export declare function createSaveApi(call: SaveCall, server?: boolean): SaveApi;
|
|
192
|
+
export declare function createSaveBucket(api: SaveApi, scope: SaveScope, subject?: string): SaveBucketApi;
|
|
193
|
+
/**
|
|
194
|
+
* Refuses a request bigger than a transport carries. An atomic save is never
|
|
195
|
+
* split, so the whole encoded request has to fit.
|
|
196
|
+
*/
|
|
197
|
+
export declare function preflightSaveRequest(verb: SaveVerb, request: unknown, limitBytes: number): void;
|