castle-web-sdk 0.4.44 → 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 +487 -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 +229 -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 +88 -6
- package/dist/multiplayer.js +219 -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 +51 -2
- package/dist/server/storage.d.ts +58 -4
- package/dist/server/storage.js +25 -5
- package/dist/server/wrapper.d.ts +147 -6
- package/dist/server/wrapper.js +741 -34
- 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/hub.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { CastleError } from "./errors";
|
|
2
|
+
import { hostRequest, setHubEventHandler } from "./transport";
|
|
3
|
+
export const Hub = {
|
|
4
|
+
go,
|
|
5
|
+
back,
|
|
6
|
+
doors,
|
|
7
|
+
friends,
|
|
8
|
+
jump,
|
|
9
|
+
report,
|
|
10
|
+
chat,
|
|
11
|
+
say,
|
|
12
|
+
reportChat,
|
|
13
|
+
cancel,
|
|
14
|
+
onBack,
|
|
15
|
+
};
|
|
16
|
+
async function go(doorId) {
|
|
17
|
+
if (typeof doorId !== "string" || doorId.length === 0) {
|
|
18
|
+
throw new CastleError({
|
|
19
|
+
code: "INVALID_ARGUMENT",
|
|
20
|
+
message: "Hub.go requires a doorId.",
|
|
21
|
+
operation: "Hub.go",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return hostRequest("hub.go", { doorId });
|
|
25
|
+
}
|
|
26
|
+
async function back() {
|
|
27
|
+
return hostRequest("hub.back", {});
|
|
28
|
+
}
|
|
29
|
+
async function doors() {
|
|
30
|
+
return hostRequest("hub.doors", {});
|
|
31
|
+
}
|
|
32
|
+
async function friends() {
|
|
33
|
+
return hostRequest("hub.friends", {});
|
|
34
|
+
}
|
|
35
|
+
async function jump(userId) {
|
|
36
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
37
|
+
throw new CastleError({
|
|
38
|
+
code: "INVALID_ARGUMENT",
|
|
39
|
+
message: "Hub.jump requires a userId.",
|
|
40
|
+
operation: "Hub.jump",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return hostRequest("hub.jump", { userId });
|
|
44
|
+
}
|
|
45
|
+
async function report(userId, reason) {
|
|
46
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
47
|
+
throw new CastleError({
|
|
48
|
+
code: "INVALID_ARGUMENT",
|
|
49
|
+
message: "Hub.report requires a userId.",
|
|
50
|
+
operation: "Hub.report",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return hostRequest("hub.report", typeof reason === "string" && reason.length > 0 ? { userId, reason } : { userId });
|
|
54
|
+
}
|
|
55
|
+
async function chat(after) {
|
|
56
|
+
return hostRequest("hub.chat", typeof after === "string" && after.length > 0 ? { after } : {});
|
|
57
|
+
}
|
|
58
|
+
async function say(text) {
|
|
59
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
60
|
+
throw new CastleError({
|
|
61
|
+
code: "INVALID_ARGUMENT",
|
|
62
|
+
message: "Hub.say requires a text.",
|
|
63
|
+
operation: "Hub.say",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return hostRequest("hub.say", { text });
|
|
67
|
+
}
|
|
68
|
+
async function reportChat(messageId) {
|
|
69
|
+
if (typeof messageId !== "string" || messageId.length === 0) {
|
|
70
|
+
throw new CastleError({
|
|
71
|
+
code: "INVALID_ARGUMENT",
|
|
72
|
+
message: "Hub.reportChat requires a messageId.",
|
|
73
|
+
operation: "Hub.reportChat",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return hostRequest("hub.reportChat", { messageId });
|
|
77
|
+
}
|
|
78
|
+
async function cancel() {
|
|
79
|
+
return hostRequest("hub.cancel", {});
|
|
80
|
+
}
|
|
81
|
+
const backListeners = new Set();
|
|
82
|
+
function onBack(listener) {
|
|
83
|
+
if (typeof listener !== "function") {
|
|
84
|
+
throw new CastleError({
|
|
85
|
+
code: "INVALID_ARGUMENT",
|
|
86
|
+
message: "Hub.onBack requires a function.",
|
|
87
|
+
operation: "Hub.onBack",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
backListeners.add(listener);
|
|
91
|
+
setHubEventHandler(dispatchHubEvent);
|
|
92
|
+
return () => {
|
|
93
|
+
backListeners.delete(listener);
|
|
94
|
+
if (backListeners.size === 0)
|
|
95
|
+
setHubEventHandler(null);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function dispatchHubEvent() {
|
|
99
|
+
for (const listener of [...backListeners]) {
|
|
100
|
+
try {
|
|
101
|
+
listener();
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.error("Castle Hub.onBack listener threw:", error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { MultiplayerDestinationAudience, 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";
|
|
5
5
|
import { type MultiplayerVoice } from "./multiplayerVoice";
|
|
6
|
+
export type { MultiplayerJoinHint } from "./commands";
|
|
6
7
|
export type MultiplayerJoinOptions = {
|
|
7
8
|
mode: "named";
|
|
8
9
|
key: string;
|
|
@@ -10,6 +11,9 @@ export type MultiplayerJoinOptions = {
|
|
|
10
11
|
mode: "party";
|
|
11
12
|
} | {
|
|
12
13
|
mode: "public";
|
|
14
|
+
} | {
|
|
15
|
+
mode: "hub";
|
|
16
|
+
handoff: HubHandoffResult;
|
|
13
17
|
};
|
|
14
18
|
export interface MultiplayerDestinationOptions {
|
|
15
19
|
routeId: string;
|
|
@@ -78,7 +82,45 @@ export type MultiplayerWorldCharacterCommand = {
|
|
|
78
82
|
type: "record_event";
|
|
79
83
|
eventTypeId: string;
|
|
80
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;
|
|
81
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;
|
|
82
124
|
export type MultiplayerWorldCharacterQuery = {
|
|
83
125
|
type: "inspect";
|
|
84
126
|
} | {
|
|
@@ -89,7 +131,33 @@ export type MultiplayerWorldCharacterQuery = {
|
|
|
89
131
|
characterId?: string;
|
|
90
132
|
limit?: number;
|
|
91
133
|
cursor?: string;
|
|
134
|
+
}
|
|
135
|
+
/** Autonomous-action notices after a cursor (world-visible). */
|
|
136
|
+
| {
|
|
137
|
+
type: "actions";
|
|
138
|
+
after?: string;
|
|
92
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
|
+
}
|
|
93
161
|
/** Authenticated, participant-scoped projection of a shared character world. */
|
|
94
162
|
export interface MultiplayerWorldCharacterInspectResult<Gameplay = unknown, SharedState = unknown, PrivateState = unknown, Source = unknown> {
|
|
95
163
|
/** Durable identity used by gameplay holders and promise debtors. */
|
|
@@ -105,11 +173,11 @@ export interface MultiplayerWorldCharacterInspectResult<Gameplay = unknown, Shar
|
|
|
105
173
|
}>;
|
|
106
174
|
sources: Source[];
|
|
107
175
|
}
|
|
108
|
-
export interface MultiplayerWorldCharacterCommandResult {
|
|
176
|
+
export interface MultiplayerWorldCharacterCommandResult<Result = unknown> {
|
|
109
177
|
operationId: string;
|
|
110
178
|
status: "applied" | "ineligible";
|
|
111
179
|
replayed: boolean;
|
|
112
|
-
result:
|
|
180
|
+
result: Result;
|
|
113
181
|
worldRevision: string;
|
|
114
182
|
}
|
|
115
183
|
export interface MultiplayerConnection {
|
|
@@ -122,17 +190,24 @@ export interface MultiplayerConnection {
|
|
|
122
190
|
* `private_deck` and `no_server_bundle` are the creator's to fix.
|
|
123
191
|
*/
|
|
124
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;
|
|
125
200
|
/** Sends byte payloads as binary frames and all other payloads as JSON. */
|
|
126
201
|
send(data: unknown): void;
|
|
127
202
|
/** Requests one billed character turn through this authenticated world socket. */
|
|
128
203
|
requestCharacterTurn(request: MultiplayerCharacterTurnRequest): Promise<MultiplayerCharacterTurnAdmission>;
|
|
129
204
|
/** Recovers the durable status of a prior operation after reconnecting. */
|
|
130
205
|
getCharacterTurnStatus(operationId: string): Promise<MultiplayerCharacterTurnStatusResult>;
|
|
131
|
-
requestCharacterCommand(request: {
|
|
206
|
+
requestCharacterCommand<Command extends MultiplayerWorldCharacterCommand>(request: {
|
|
132
207
|
requestId: string;
|
|
133
208
|
operationId: string;
|
|
134
|
-
command:
|
|
135
|
-
}): Promise<MultiplayerWorldCharacterCommandResult
|
|
209
|
+
command: Command;
|
|
210
|
+
}): Promise<MultiplayerWorldCharacterCommandResult<MultiplayerWorldCharacterCommandResultValue<Command>>>;
|
|
136
211
|
readCharacters(request: {
|
|
137
212
|
requestId: string;
|
|
138
213
|
query: {
|
|
@@ -152,7 +227,14 @@ export interface MultiplayerConnection {
|
|
|
152
227
|
leave(): void;
|
|
153
228
|
}
|
|
154
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;
|
|
155
236
|
join(options: MultiplayerJoinOptions): Promise<MultiplayerConnection>;
|
|
237
|
+
joinHint(): Promise<MultiplayerJoinHint | null>;
|
|
156
238
|
joinDestination(options: MultiplayerDestinationOptions): Promise<MultiplayerDestinationJoinResult>;
|
|
157
239
|
}
|
|
158
240
|
export declare const Multiplayer: CastleMultiplayerApi;
|
package/dist/multiplayer.js
CHANGED
|
@@ -18,20 +18,88 @@ 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
|
+
};
|
|
27
|
+
async function joinHint() {
|
|
28
|
+
try {
|
|
29
|
+
return await hostRequest("multiplayer.getJoinHint", {});
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof CastleError && error.code === "UNKNOWN_COMMAND")
|
|
33
|
+
return null;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
22
37
|
async function join(options) {
|
|
23
|
-
const
|
|
38
|
+
const hub = hubJoin(options);
|
|
39
|
+
const request = hub
|
|
40
|
+
? { command: "hub", nonce: hub.nonce }
|
|
41
|
+
: { command: "multiplayer.getSession", params: joinParams(options) };
|
|
24
42
|
latestJoinAttempt += 1;
|
|
25
43
|
cancelPendingDestinationJoin();
|
|
26
44
|
activeConnection?.leave();
|
|
27
|
-
const connection = new MultiplayerConnectionImpl(
|
|
45
|
+
const connection = new MultiplayerConnectionImpl(request, () => {
|
|
28
46
|
if (activeConnection === connection)
|
|
29
47
|
activeConnection = null;
|
|
30
48
|
});
|
|
31
49
|
activeConnection = connection;
|
|
32
|
-
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();
|
|
33
59
|
return connection;
|
|
34
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
|
+
}
|
|
35
103
|
async function joinDestination(options) {
|
|
36
104
|
const params = destinationParams(options);
|
|
37
105
|
const attempt = ++latestJoinAttempt;
|
|
@@ -102,6 +170,8 @@ async function joinDestination(options) {
|
|
|
102
170
|
activeConnection = connection;
|
|
103
171
|
return { status: "joined", connection, audience: acquired.audience };
|
|
104
172
|
}
|
|
173
|
+
// How many messages are kept for a deck that has not attached a listener yet.
|
|
174
|
+
const EARLY_MESSAGES_MAX = 512;
|
|
105
175
|
class MultiplayerConnectionImpl {
|
|
106
176
|
request;
|
|
107
177
|
onClosed;
|
|
@@ -119,10 +189,22 @@ class MultiplayerConnectionImpl {
|
|
|
119
189
|
keepalive = null;
|
|
120
190
|
reAcquisitionUsed = false;
|
|
121
191
|
recovering = false;
|
|
192
|
+
hubSettled = false;
|
|
193
|
+
hubCommit = Promise.resolve(true);
|
|
122
194
|
roster = null;
|
|
123
195
|
sharedCharactersSupported = false;
|
|
124
196
|
pendingCharacterRequests = new Map();
|
|
125
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;
|
|
126
208
|
rosters = new Set();
|
|
127
209
|
states = new Set();
|
|
128
210
|
voiceImpl = new MultiplayerVoiceImpl({
|
|
@@ -150,6 +232,9 @@ class MultiplayerConnectionImpl {
|
|
|
150
232
|
get soloReason() {
|
|
151
233
|
return this.currentSoloReason;
|
|
152
234
|
}
|
|
235
|
+
get sharedCharacters() {
|
|
236
|
+
return this.sharedCharactersSupported;
|
|
237
|
+
}
|
|
153
238
|
get voice() {
|
|
154
239
|
return this.voiceImpl;
|
|
155
240
|
}
|
|
@@ -158,6 +243,7 @@ class MultiplayerConnectionImpl {
|
|
|
158
243
|
await this.acquire(initial);
|
|
159
244
|
}
|
|
160
245
|
catch (error) {
|
|
246
|
+
this.settleHub("cancel");
|
|
161
247
|
const castleError = asCastleError(error);
|
|
162
248
|
if (this.currentState !== "closed") {
|
|
163
249
|
this.finish({ type: "error", error: castleError });
|
|
@@ -239,8 +325,43 @@ class MultiplayerConnectionImpl {
|
|
|
239
325
|
}
|
|
240
326
|
onMessage(callback) {
|
|
241
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
|
+
}
|
|
242
340
|
return () => this.messages.delete(callback);
|
|
243
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
|
+
}
|
|
244
365
|
onRoster(callback) {
|
|
245
366
|
this.rosters.add(callback);
|
|
246
367
|
if (this.roster)
|
|
@@ -255,8 +376,27 @@ class MultiplayerConnectionImpl {
|
|
|
255
376
|
leave() {
|
|
256
377
|
if (this.currentState === "closed")
|
|
257
378
|
return;
|
|
379
|
+
this.settleHub("cancel");
|
|
258
380
|
this.finish({ type: "left" });
|
|
259
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
|
+
}
|
|
260
400
|
matchesDestination(params) {
|
|
261
401
|
return (this.currentState !== "closed" &&
|
|
262
402
|
this.request.command === "multiplayer.joinDestination" &&
|
|
@@ -319,6 +459,8 @@ class MultiplayerConnectionImpl {
|
|
|
319
459
|
}
|
|
320
460
|
connectSolo(result) {
|
|
321
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;
|
|
322
464
|
this.currentSoloReason = result.soloReason ?? "unavailable";
|
|
323
465
|
this.currentPlayerId = "solo";
|
|
324
466
|
this.currentSessionId = result.sessionId ?? "solo";
|
|
@@ -412,6 +554,7 @@ class MultiplayerConnectionImpl {
|
|
|
412
554
|
// is backgrounded many times in a play, and each of those is its own outage;
|
|
413
555
|
// without this the second one finds the budget spent and closes for good.
|
|
414
556
|
this.reAcquisitionUsed = false;
|
|
557
|
+
this.settleHub("commit");
|
|
415
558
|
this.transition("connected");
|
|
416
559
|
this.startKeepalive();
|
|
417
560
|
void this.voiceImpl.attach();
|
|
@@ -422,8 +565,7 @@ class MultiplayerConnectionImpl {
|
|
|
422
565
|
this.acceptRoster(frame);
|
|
423
566
|
break;
|
|
424
567
|
case "m":
|
|
425
|
-
|
|
426
|
-
callListener(callback, frame.d);
|
|
568
|
+
this.deliver(frame.d);
|
|
427
569
|
break;
|
|
428
570
|
case "worldCharacterResult":
|
|
429
571
|
this.acceptWorldCharacterResult(frame);
|
|
@@ -448,8 +590,7 @@ class MultiplayerConnectionImpl {
|
|
|
448
590
|
if (bytes.byteLength < 1 || bytes[0] !== BINARY_MESSAGE_TAG)
|
|
449
591
|
return;
|
|
450
592
|
const payload = new Uint8Array(buffer, 1);
|
|
451
|
-
|
|
452
|
-
callListener(callback, payload);
|
|
593
|
+
this.deliver(payload);
|
|
453
594
|
}
|
|
454
595
|
acceptRoster(frame) {
|
|
455
596
|
const players = frame.players.slice();
|
|
@@ -559,6 +700,7 @@ class MultiplayerConnectionImpl {
|
|
|
559
700
|
this.socketAttempt += 1;
|
|
560
701
|
closeSocket(socket, 1000, "session closed");
|
|
561
702
|
this.currentState = "closed";
|
|
703
|
+
this.early = null;
|
|
562
704
|
this.voiceImpl.dispose();
|
|
563
705
|
for (const callback of this.states)
|
|
564
706
|
callStateListener(callback, "closed", metadata);
|
|
@@ -675,6 +817,11 @@ function cancelPendingDestinationJoin() {
|
|
|
675
817
|
pending?.connection?.leave();
|
|
676
818
|
}
|
|
677
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
|
+
}
|
|
678
825
|
if (request.command === "multiplayer.getSession") {
|
|
679
826
|
return hostRequest(request.command, request.params);
|
|
680
827
|
}
|
|
@@ -768,6 +915,42 @@ function normalizeWorldCharacterCommandRequest(request) {
|
|
|
768
915
|
type: "record_event",
|
|
769
916
|
eventTypeId: boundedCharacterText(command.eventTypeId, 128, "command.eventTypeId", WORLD_CHARACTER_ID_MAX_CHARACTERS),
|
|
770
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"),
|
|
771
954
|
},
|
|
772
955
|
};
|
|
773
956
|
}
|
|
@@ -798,8 +981,20 @@ function normalizeWorldCharacterReadRequest(request) {
|
|
|
798
981
|
},
|
|
799
982
|
};
|
|
800
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
|
+
}
|
|
801
990
|
throw characterError("INVALID_ARGUMENT", "Unknown shared character query type.");
|
|
802
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
|
+
}
|
|
803
998
|
function normalizeJson(value, field) {
|
|
804
999
|
try {
|
|
805
1000
|
const text = JSON.stringify(value);
|
|
@@ -821,6 +1016,22 @@ function boundedCharacterIds(value, maxItems, field, maxCharacters = 128) {
|
|
|
821
1016
|
}
|
|
822
1017
|
return ids;
|
|
823
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
|
+
}
|
|
824
1035
|
function boundedCharacterText(value, maxBytes, field, maxCharacters = maxBytes) {
|
|
825
1036
|
if (typeof value !== "string" ||
|
|
826
1037
|
value.length === 0 ||
|