castle-web-sdk 0.4.36 → 0.4.37
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/dist/castle.d.ts +3 -3
- package/dist/commands.d.ts +46 -1
- package/dist/multiplayer.d.ts +13 -1
- package/dist/multiplayer.js +153 -11
- package/dist/multiplayerVoice.d.ts +26 -2
- package/dist/multiplayerVoice.js +118 -3
- package/dist/server/platformHandle.d.ts +13 -0
- package/dist/server/wrapper.d.ts +30 -1
- package/dist/server/wrapper.js +92 -0
- package/dist/transport.js +1 -0
- package/package.json +1 -1
package/dist/castle.d.ts
CHANGED
|
@@ -14,9 +14,9 @@ export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, Leaderboard
|
|
|
14
14
|
export { Lifecycle } from "./lifecycle";
|
|
15
15
|
export type { CastleLifecycleApi } from "./lifecycle";
|
|
16
16
|
export { Multiplayer } from "./multiplayer";
|
|
17
|
-
export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
|
|
18
|
-
export type { MultiplayerVoice, VoiceJoinStatus, VoiceMixEntry, VoiceState, } from "./multiplayerVoice";
|
|
19
|
-
export type { MultiplayerSoloReason } from "./commands";
|
|
17
|
+
export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerDestinationJoinResult, MultiplayerDestinationOptions, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
|
|
18
|
+
export type { MultiplayerVoice, VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector, } from "./multiplayerVoice";
|
|
19
|
+
export type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
|
|
20
20
|
export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
|
|
21
21
|
export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
|
|
22
22
|
export { registerParams } from "./paramsRegistry";
|
package/dist/commands.d.ts
CHANGED
|
@@ -103,14 +103,51 @@ export interface MultiplayerGetSessionResult {
|
|
|
103
103
|
/** Internal local-serve scope for restoring this Play panel after a reload. */
|
|
104
104
|
devReconnectScope?: string;
|
|
105
105
|
}
|
|
106
|
+
export interface MultiplayerJoinDestinationParams {
|
|
107
|
+
routeId: string;
|
|
108
|
+
slot: string;
|
|
109
|
+
}
|
|
110
|
+
export type MultiplayerDestinationAudience = "player" | "party" | "public";
|
|
111
|
+
export type MultiplayerJoinDestinationStatus = "ok" | "cancelled" | "full" | "unavailable" | "not_found" | "forbidden" | "party_required";
|
|
112
|
+
export interface MultiplayerJoinDestinationResult {
|
|
113
|
+
status: MultiplayerJoinDestinationStatus;
|
|
114
|
+
url: string | null;
|
|
115
|
+
nonce: string | null;
|
|
116
|
+
sessionId: string | null;
|
|
117
|
+
audience: MultiplayerDestinationAudience | null;
|
|
118
|
+
}
|
|
106
119
|
export type VoiceState = "unavailable" | "off" | "joining" | "live";
|
|
107
120
|
export type VoiceJoinStatus = "joined" | "declined" | "unavailable";
|
|
108
121
|
export interface VoiceMixEntry {
|
|
109
122
|
/** 0 (silent) to 1 (full). */
|
|
110
123
|
gain?: number;
|
|
111
|
-
/** -1 (left) to 1 (right). */
|
|
124
|
+
/** -1 (left) to 1 (right). Ignored for a player with a 3D position. */
|
|
112
125
|
pan?: number;
|
|
113
126
|
}
|
|
127
|
+
export type VoiceVector = [number, number, number];
|
|
128
|
+
export interface VoiceListener {
|
|
129
|
+
position: VoiceVector;
|
|
130
|
+
/** The direction the listener faces; need not be normalized. */
|
|
131
|
+
forward: VoiceVector;
|
|
132
|
+
/** Defaults to [0, 1, 0]. */
|
|
133
|
+
up?: VoiceVector;
|
|
134
|
+
}
|
|
135
|
+
export interface VoiceSpatialOptions {
|
|
136
|
+
/** How volume falls off with distance; default "inverse". */
|
|
137
|
+
distanceModel?: "linear" | "inverse" | "exponential";
|
|
138
|
+
/** Distance at which a voice is at full volume; default 1. */
|
|
139
|
+
refDistance?: number;
|
|
140
|
+
/** Beyond this, a voice gets no quieter ("linear": silent); default 100. */
|
|
141
|
+
maxDistance?: number;
|
|
142
|
+
/** How fast volume falls off; default 1. */
|
|
143
|
+
rolloff?: number;
|
|
144
|
+
}
|
|
145
|
+
export interface VoiceSpatialUpdate {
|
|
146
|
+
listener?: VoiceListener;
|
|
147
|
+
/** Keyed by userId. `null` returns a player to plain gain and pan. */
|
|
148
|
+
positions?: Record<string, VoiceVector | null>;
|
|
149
|
+
options?: VoiceSpatialOptions;
|
|
150
|
+
}
|
|
114
151
|
export interface CharacterSummary {
|
|
115
152
|
id: string;
|
|
116
153
|
version: string;
|
|
@@ -303,6 +340,7 @@ export interface CommandParams {
|
|
|
303
340
|
style: HapticStyle;
|
|
304
341
|
};
|
|
305
342
|
"multiplayer.getSession": MultiplayerGetSessionParams;
|
|
343
|
+
"multiplayer.joinDestination": MultiplayerJoinDestinationParams;
|
|
306
344
|
"voice.getState": {
|
|
307
345
|
sessionId: string;
|
|
308
346
|
};
|
|
@@ -316,6 +354,9 @@ export interface CommandParams {
|
|
|
316
354
|
sessionId: string;
|
|
317
355
|
mix: Record<string, VoiceMixEntry>;
|
|
318
356
|
};
|
|
357
|
+
"voice.setSpatial": {
|
|
358
|
+
sessionId: string;
|
|
359
|
+
} & VoiceSpatialUpdate;
|
|
319
360
|
"characters.open": {
|
|
320
361
|
characterId: string;
|
|
321
362
|
};
|
|
@@ -438,6 +479,7 @@ export interface CommandResult {
|
|
|
438
479
|
"portal.info": PortalInfoResult;
|
|
439
480
|
"haptics.play": HapticsResult;
|
|
440
481
|
"multiplayer.getSession": MultiplayerGetSessionResult;
|
|
482
|
+
"multiplayer.joinDestination": MultiplayerJoinDestinationResult;
|
|
441
483
|
"voice.getState": {
|
|
442
484
|
state: VoiceState;
|
|
443
485
|
};
|
|
@@ -450,6 +492,9 @@ export interface CommandResult {
|
|
|
450
492
|
"voice.setMix": {
|
|
451
493
|
ok: true;
|
|
452
494
|
};
|
|
495
|
+
"voice.setSpatial": {
|
|
496
|
+
ok: true;
|
|
497
|
+
};
|
|
453
498
|
"characters.open": CharacterAdmission;
|
|
454
499
|
"characters.send": CharacterOperation;
|
|
455
500
|
"characters.status": CharacterOperation;
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MultiplayerSoloReason } from "./commands";
|
|
1
|
+
import type { MultiplayerDestinationAudience, MultiplayerSoloReason } from "./commands";
|
|
2
2
|
import { CastleError } from "./errors";
|
|
3
3
|
import { type MultiplayerPlayer } from "./multiplayerProtocol";
|
|
4
4
|
import { type MultiplayerVoice } from "./multiplayerVoice";
|
|
@@ -10,6 +10,17 @@ export type MultiplayerJoinOptions = {
|
|
|
10
10
|
} | {
|
|
11
11
|
mode: "public";
|
|
12
12
|
};
|
|
13
|
+
export interface MultiplayerDestinationOptions {
|
|
14
|
+
routeId: string;
|
|
15
|
+
slot: string;
|
|
16
|
+
}
|
|
17
|
+
export type MultiplayerDestinationJoinResult = {
|
|
18
|
+
status: "joined";
|
|
19
|
+
connection: MultiplayerConnection;
|
|
20
|
+
audience: MultiplayerDestinationAudience;
|
|
21
|
+
} | {
|
|
22
|
+
status: "cancelled" | "full" | "unavailable" | "not_found" | "forbidden" | "party_required";
|
|
23
|
+
};
|
|
13
24
|
export type MultiplayerConnectionState = "acquiring" | "connecting" | "connected" | "reconnecting" | "closed";
|
|
14
25
|
export type MultiplayerStateMetadata = {
|
|
15
26
|
type: "connectionLost";
|
|
@@ -50,5 +61,6 @@ export interface MultiplayerConnection {
|
|
|
50
61
|
}
|
|
51
62
|
export interface CastleMultiplayerApi {
|
|
52
63
|
join(options: MultiplayerJoinOptions): Promise<MultiplayerConnection>;
|
|
64
|
+
joinDestination(options: MultiplayerDestinationOptions): Promise<MultiplayerDestinationJoinResult>;
|
|
53
65
|
}
|
|
54
66
|
export declare const Multiplayer: CastleMultiplayerApi;
|
package/dist/multiplayer.js
CHANGED
|
@@ -8,11 +8,15 @@ const RECONNECT_BUDGET_MS = 30_000;
|
|
|
8
8
|
const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
|
|
9
9
|
const INVALID_DEV_RECONNECT_CLOSE_CODE = 4003;
|
|
10
10
|
let activeConnection = null;
|
|
11
|
-
|
|
11
|
+
let latestJoinAttempt = 0;
|
|
12
|
+
let pendingDestinationJoin = null;
|
|
13
|
+
export const Multiplayer = { join, joinDestination };
|
|
12
14
|
async function join(options) {
|
|
13
15
|
const params = joinParams(options);
|
|
16
|
+
latestJoinAttempt += 1;
|
|
17
|
+
cancelPendingDestinationJoin();
|
|
14
18
|
activeConnection?.leave();
|
|
15
|
-
const connection = new MultiplayerConnectionImpl(params, () => {
|
|
19
|
+
const connection = new MultiplayerConnectionImpl({ command: "multiplayer.getSession", params }, () => {
|
|
16
20
|
if (activeConnection === connection)
|
|
17
21
|
activeConnection = null;
|
|
18
22
|
});
|
|
@@ -20,9 +24,80 @@ async function join(options) {
|
|
|
20
24
|
await connection.start();
|
|
21
25
|
return connection;
|
|
22
26
|
}
|
|
27
|
+
async function joinDestination(options) {
|
|
28
|
+
const params = destinationParams(options);
|
|
29
|
+
const attempt = ++latestJoinAttempt;
|
|
30
|
+
cancelPendingDestinationJoin();
|
|
31
|
+
if (activeConnection?.matchesDestination(params)) {
|
|
32
|
+
return {
|
|
33
|
+
status: "joined",
|
|
34
|
+
connection: activeConnection,
|
|
35
|
+
audience: activeConnection.destinationAudience,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const previousConnection = activeConnection;
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const pending = { controller, connection: null };
|
|
41
|
+
pendingDestinationJoin = pending;
|
|
42
|
+
let acquired;
|
|
43
|
+
try {
|
|
44
|
+
acquired = await hostRequest("multiplayer.joinDestination", params, {
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (pendingDestinationJoin === pending)
|
|
50
|
+
pendingDestinationJoin = null;
|
|
51
|
+
if (attempt !== latestJoinAttempt || controller.signal.aborted) {
|
|
52
|
+
return { status: "cancelled" };
|
|
53
|
+
}
|
|
54
|
+
const status = destinationStatusFromError(error);
|
|
55
|
+
if (status)
|
|
56
|
+
return { status };
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
if (attempt !== latestJoinAttempt)
|
|
60
|
+
return { status: "cancelled" };
|
|
61
|
+
if (acquired.status !== "ok") {
|
|
62
|
+
if (pendingDestinationJoin === pending)
|
|
63
|
+
pendingDestinationJoin = null;
|
|
64
|
+
return { status: acquired.status };
|
|
65
|
+
}
|
|
66
|
+
if (!acquired.url || !acquired.nonce || !acquired.audience) {
|
|
67
|
+
if (pendingDestinationJoin === pending)
|
|
68
|
+
pendingDestinationJoin = null;
|
|
69
|
+
return { status: "unavailable" };
|
|
70
|
+
}
|
|
71
|
+
const connection = new MultiplayerConnectionImpl({ command: "multiplayer.joinDestination", params }, () => {
|
|
72
|
+
if (activeConnection === connection)
|
|
73
|
+
activeConnection = null;
|
|
74
|
+
}, acquired.audience);
|
|
75
|
+
pending.connection = connection;
|
|
76
|
+
try {
|
|
77
|
+
await connection.start(acquired);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (pendingDestinationJoin === pending)
|
|
81
|
+
pendingDestinationJoin = null;
|
|
82
|
+
if (attempt !== latestJoinAttempt || controller.signal.aborted) {
|
|
83
|
+
return { status: "cancelled" };
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
if (pendingDestinationJoin === pending)
|
|
88
|
+
pendingDestinationJoin = null;
|
|
89
|
+
if (attempt !== latestJoinAttempt) {
|
|
90
|
+
connection.leave();
|
|
91
|
+
return { status: "cancelled" };
|
|
92
|
+
}
|
|
93
|
+
previousConnection?.leave();
|
|
94
|
+
activeConnection = connection;
|
|
95
|
+
return { status: "joined", connection, audience: acquired.audience };
|
|
96
|
+
}
|
|
23
97
|
class MultiplayerConnectionImpl {
|
|
24
|
-
|
|
98
|
+
request;
|
|
25
99
|
onClosed;
|
|
100
|
+
destinationAudience;
|
|
26
101
|
currentState = "acquiring";
|
|
27
102
|
currentPlayerId = "";
|
|
28
103
|
currentSessionId = "";
|
|
@@ -45,9 +120,10 @@ class MultiplayerConnectionImpl {
|
|
|
45
120
|
players: () => this.roster?.players ?? [],
|
|
46
121
|
isSolo: () => this.solo,
|
|
47
122
|
});
|
|
48
|
-
constructor(
|
|
49
|
-
this.
|
|
123
|
+
constructor(request, onClosed, destinationAudience = null) {
|
|
124
|
+
this.request = request;
|
|
50
125
|
this.onClosed = onClosed;
|
|
126
|
+
this.destinationAudience = destinationAudience;
|
|
51
127
|
}
|
|
52
128
|
get state() {
|
|
53
129
|
return this.currentState;
|
|
@@ -67,9 +143,9 @@ class MultiplayerConnectionImpl {
|
|
|
67
143
|
get voice() {
|
|
68
144
|
return this.voiceImpl;
|
|
69
145
|
}
|
|
70
|
-
async start() {
|
|
146
|
+
async start(initial) {
|
|
71
147
|
try {
|
|
72
|
-
await this.acquire();
|
|
148
|
+
await this.acquire(initial);
|
|
73
149
|
}
|
|
74
150
|
catch (error) {
|
|
75
151
|
const castleError = asCastleError(error);
|
|
@@ -117,9 +193,16 @@ class MultiplayerConnectionImpl {
|
|
|
117
193
|
return;
|
|
118
194
|
this.finish({ type: "left" });
|
|
119
195
|
}
|
|
120
|
-
|
|
196
|
+
matchesDestination(params) {
|
|
197
|
+
return (this.currentState !== "closed" &&
|
|
198
|
+
this.request.command === "multiplayer.joinDestination" &&
|
|
199
|
+
this.request.params.routeId === params.routeId &&
|
|
200
|
+
this.request.params.slot === params.slot &&
|
|
201
|
+
this.destinationAudience !== null);
|
|
202
|
+
}
|
|
203
|
+
async acquire(initial) {
|
|
121
204
|
this.transition("acquiring");
|
|
122
|
-
const result = await
|
|
205
|
+
const result = initial ?? await requestAcquisition(this.request);
|
|
123
206
|
this.assertOpen();
|
|
124
207
|
if (result.status === "solo") {
|
|
125
208
|
this.connectSolo(result);
|
|
@@ -131,8 +214,11 @@ class MultiplayerConnectionImpl {
|
|
|
131
214
|
this.solo = false;
|
|
132
215
|
this.currentSoloReason = null;
|
|
133
216
|
this.baseUrl = result.url;
|
|
134
|
-
|
|
135
|
-
?
|
|
217
|
+
const devReconnectScope = "devReconnectScope" in result
|
|
218
|
+
? result.devReconnectScope
|
|
219
|
+
: undefined;
|
|
220
|
+
this.devReconnectStorageKey = devReconnectScope
|
|
221
|
+
? `castle-dev-mp-reconnect:${result.url}:${result.sessionId}:${devReconnectScope}`
|
|
136
222
|
: null;
|
|
137
223
|
this.transition("connecting");
|
|
138
224
|
const savedToken = this.devReconnectStorageKey
|
|
@@ -436,6 +522,62 @@ function joinParams(options) {
|
|
|
436
522
|
}
|
|
437
523
|
throw invalidJoinOptions();
|
|
438
524
|
}
|
|
525
|
+
function destinationParams(options) {
|
|
526
|
+
if (!options ||
|
|
527
|
+
typeof options !== "object" ||
|
|
528
|
+
typeof options.routeId !== "string" ||
|
|
529
|
+
!/^[A-Za-z0-9_-]{1,64}$/.test(options.routeId) ||
|
|
530
|
+
typeof options.slot !== "string" ||
|
|
531
|
+
options.slot.length === 0 ||
|
|
532
|
+
options.slot.includes("\0") ||
|
|
533
|
+
!isValidUnicode(options.slot) ||
|
|
534
|
+
new TextEncoder().encode(options.slot).byteLength > 128) {
|
|
535
|
+
throw multiplayerError("INVALID_ARGUMENT", "Multiplayer.joinDestination requires a valid routeId and a non-empty slot of at most 128 bytes.", "Multiplayer.joinDestination");
|
|
536
|
+
}
|
|
537
|
+
return { routeId: options.routeId, slot: options.slot };
|
|
538
|
+
}
|
|
539
|
+
function cancelPendingDestinationJoin() {
|
|
540
|
+
const pending = pendingDestinationJoin;
|
|
541
|
+
pendingDestinationJoin = null;
|
|
542
|
+
pending?.controller.abort();
|
|
543
|
+
pending?.connection?.leave();
|
|
544
|
+
}
|
|
545
|
+
async function requestAcquisition(request) {
|
|
546
|
+
if (request.command === "multiplayer.getSession") {
|
|
547
|
+
return hostRequest(request.command, request.params);
|
|
548
|
+
}
|
|
549
|
+
return hostRequest(request.command, request.params);
|
|
550
|
+
}
|
|
551
|
+
function destinationStatusFromError(error) {
|
|
552
|
+
if (!(error instanceof CastleError))
|
|
553
|
+
return null;
|
|
554
|
+
switch (error.code) {
|
|
555
|
+
case "CAULDRON_DESTINATION_NOT_FOUND":
|
|
556
|
+
return "not_found";
|
|
557
|
+
case "CAULDRON_DESTINATION_FORBIDDEN":
|
|
558
|
+
return "forbidden";
|
|
559
|
+
case "CAULDRON_DESTINATION_PARTY_REQUIRED":
|
|
560
|
+
return "party_required";
|
|
561
|
+
case "CAULDRON_DESTINATION_FULL":
|
|
562
|
+
return "full";
|
|
563
|
+
case "CAULDRON_DESTINATION_UNAVAILABLE":
|
|
564
|
+
case "CAULDRON_DESTINATION_CONFIG_CHANGED":
|
|
565
|
+
return "unavailable";
|
|
566
|
+
case "CANCELLED":
|
|
567
|
+
return "cancelled";
|
|
568
|
+
default:
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function isValidUnicode(value) {
|
|
573
|
+
try {
|
|
574
|
+
encodeURIComponent(value);
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
439
581
|
function invalidJoinOptions() {
|
|
440
582
|
return multiplayerError("INVALID_ARGUMENT", "Multiplayer.join requires named, party, or public options.");
|
|
441
583
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { VoiceEvent, VoiceJoinStatus, VoiceMixEntry, VoiceState } from "./commands";
|
|
1
|
+
import type { VoiceEvent, VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector } from "./commands";
|
|
2
2
|
import type { MultiplayerPlayer } from "./multiplayerProtocol";
|
|
3
3
|
export declare const VOICE_MIX_INTERVAL_MS = 100;
|
|
4
|
-
export type { VoiceJoinStatus, VoiceMixEntry, VoiceState };
|
|
4
|
+
export type { VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector, };
|
|
5
5
|
export interface MultiplayerVoice {
|
|
6
6
|
/** "unavailable" when this host, deck, session or player can't have voice. */
|
|
7
7
|
readonly state: VoiceState;
|
|
@@ -18,6 +18,19 @@ export interface MultiplayerVoice {
|
|
|
18
18
|
* full volume, centered). Sent at most ten times a second; the latest values win.
|
|
19
19
|
*/
|
|
20
20
|
setMix(mix: Record<string, VoiceMixEntry>): void;
|
|
21
|
+
/**
|
|
22
|
+
* 3D voice: where the listener (usually the local player's camera) is and faces, in
|
|
23
|
+
* the deck's world units, y up. With positions set, voices come from their place in
|
|
24
|
+
* the world (distance, left/right, front/behind, above/below; best on headphones).
|
|
25
|
+
*/
|
|
26
|
+
setListener(listener: VoiceListener): void;
|
|
27
|
+
/**
|
|
28
|
+
* Where each player's voice comes from, keyed by playerId. `null` returns a player
|
|
29
|
+
* to plain gain and pan. Their `setMix` gain still applies; their pan does not.
|
|
30
|
+
*/
|
|
31
|
+
setPositions(positions: Record<string, VoiceVector | null>): void;
|
|
32
|
+
/** How voices fade with distance. Defaults: inverse, refDistance 1, maxDistance 100, rolloff 1. */
|
|
33
|
+
setSpatialOptions(options: VoiceSpatialOptions): void;
|
|
21
34
|
onState(callback: (state: VoiceState) => void): () => void;
|
|
22
35
|
/** The playerIds speaking right now; called whenever that set changes. */
|
|
23
36
|
onSpeaking(callback: (playerIds: string[]) => void): () => void;
|
|
@@ -39,6 +52,12 @@ export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
|
|
|
39
52
|
private speaking;
|
|
40
53
|
private pendingMix;
|
|
41
54
|
private sentMix;
|
|
55
|
+
private pendingListener;
|
|
56
|
+
private pendingPositions;
|
|
57
|
+
private pendingOptions;
|
|
58
|
+
private sentListener;
|
|
59
|
+
private sentPositions;
|
|
60
|
+
private sentOptions;
|
|
42
61
|
private mixTimer;
|
|
43
62
|
private lastMixSentAt;
|
|
44
63
|
private disposed;
|
|
@@ -51,6 +70,9 @@ export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
|
|
|
51
70
|
private askToJoin;
|
|
52
71
|
leave(): void;
|
|
53
72
|
setMix(mix: Record<string, VoiceMixEntry>): void;
|
|
73
|
+
setListener(listener: VoiceListener): void;
|
|
74
|
+
setPositions(positions: Record<string, VoiceVector | null>): void;
|
|
75
|
+
setSpatialOptions(options: VoiceSpatialOptions): void;
|
|
54
76
|
onState(callback: (state: VoiceState) => void): () => void;
|
|
55
77
|
onSpeaking(callback: (playerIds: string[]) => void): () => void;
|
|
56
78
|
receive(event: VoiceEvent): void;
|
|
@@ -62,6 +84,8 @@ export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
|
|
|
62
84
|
private sendLeave;
|
|
63
85
|
private setState;
|
|
64
86
|
private emitSpeaking;
|
|
87
|
+
private hasPending;
|
|
65
88
|
private scheduleMix;
|
|
66
89
|
private flushMix;
|
|
90
|
+
private takeSpatial;
|
|
67
91
|
}
|
package/dist/multiplayerVoice.js
CHANGED
|
@@ -31,6 +31,13 @@ export class MultiplayerVoiceImpl {
|
|
|
31
31
|
speaking = [];
|
|
32
32
|
pendingMix = {};
|
|
33
33
|
sentMix = {};
|
|
34
|
+
// Spatial state: pending (not yet sent) and sent (re-sent when a call restarts).
|
|
35
|
+
pendingListener = null;
|
|
36
|
+
pendingPositions = {};
|
|
37
|
+
pendingOptions = null;
|
|
38
|
+
sentListener = null;
|
|
39
|
+
sentPositions = {};
|
|
40
|
+
sentOptions = null;
|
|
34
41
|
mixTimer = null;
|
|
35
42
|
lastMixSentAt = 0;
|
|
36
43
|
disposed = false;
|
|
@@ -163,6 +170,45 @@ export class MultiplayerVoiceImpl {
|
|
|
163
170
|
}
|
|
164
171
|
this.scheduleMix();
|
|
165
172
|
}
|
|
173
|
+
setListener(listener) {
|
|
174
|
+
if (this.disposed || !listener || typeof listener !== "object")
|
|
175
|
+
return;
|
|
176
|
+
const position = vector(listener.position);
|
|
177
|
+
const forward = vector(listener.forward);
|
|
178
|
+
const up = listener.up === undefined ? undefined : vector(listener.up);
|
|
179
|
+
// A zero direction faces nowhere (a player standing still, say): keep the last
|
|
180
|
+
// listener rather than send one the host would drop.
|
|
181
|
+
if (!position || !forward || up === null || isZero(forward) || (up && isZero(up)))
|
|
182
|
+
return;
|
|
183
|
+
this.pendingListener = up ? { position, forward, up } : { position, forward };
|
|
184
|
+
this.scheduleMix();
|
|
185
|
+
}
|
|
186
|
+
setPositions(positions) {
|
|
187
|
+
if (this.disposed || !positions || typeof positions !== "object")
|
|
188
|
+
return;
|
|
189
|
+
const players = this.access.players();
|
|
190
|
+
for (const [playerId, value] of Object.entries(positions)) {
|
|
191
|
+
const player = players.find((candidate) => candidate.playerId === playerId);
|
|
192
|
+
if (!player)
|
|
193
|
+
continue;
|
|
194
|
+
if (value === null) {
|
|
195
|
+
this.pendingPositions[player.userId] = null;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const position = vector(value);
|
|
199
|
+
if (position)
|
|
200
|
+
this.pendingPositions[player.userId] = position;
|
|
201
|
+
}
|
|
202
|
+
this.scheduleMix();
|
|
203
|
+
}
|
|
204
|
+
setSpatialOptions(options) {
|
|
205
|
+
if (this.disposed || !options || typeof options !== "object")
|
|
206
|
+
return;
|
|
207
|
+
// Cumulative: the host fills every missing field with its default, so each send
|
|
208
|
+
// carries everything the deck has set so far.
|
|
209
|
+
this.pendingOptions = { ...this.sentOptions, ...this.pendingOptions, ...options };
|
|
210
|
+
this.scheduleMix();
|
|
211
|
+
}
|
|
166
212
|
onState(callback) {
|
|
167
213
|
this.states.add(callback);
|
|
168
214
|
callListener(callback, this.currentState);
|
|
@@ -198,6 +244,14 @@ export class MultiplayerVoiceImpl {
|
|
|
198
244
|
if (!userIds.has(userId))
|
|
199
245
|
delete this.sentMix[userId];
|
|
200
246
|
}
|
|
247
|
+
for (const userId of Object.keys(this.pendingPositions)) {
|
|
248
|
+
if (!userIds.has(userId))
|
|
249
|
+
delete this.pendingPositions[userId];
|
|
250
|
+
}
|
|
251
|
+
for (const userId of Object.keys(this.sentPositions)) {
|
|
252
|
+
if (!userIds.has(userId))
|
|
253
|
+
delete this.sentPositions[userId];
|
|
254
|
+
}
|
|
201
255
|
this.emitSpeaking();
|
|
202
256
|
}
|
|
203
257
|
// The game connection ended: voice for its session goes with it.
|
|
@@ -230,6 +284,12 @@ export class MultiplayerVoiceImpl {
|
|
|
230
284
|
this.speakingUserIds = [];
|
|
231
285
|
this.pendingMix = {};
|
|
232
286
|
this.sentMix = {};
|
|
287
|
+
this.pendingListener = null;
|
|
288
|
+
this.pendingPositions = {};
|
|
289
|
+
this.pendingOptions = null;
|
|
290
|
+
this.sentListener = null;
|
|
291
|
+
this.sentPositions = {};
|
|
292
|
+
this.sentOptions = null;
|
|
233
293
|
this.emitSpeaking();
|
|
234
294
|
}
|
|
235
295
|
sendLeave(sessionId) {
|
|
@@ -249,6 +309,12 @@ export class MultiplayerVoiceImpl {
|
|
|
249
309
|
for (const userId of Object.keys(this.sentMix)) {
|
|
250
310
|
this.pendingMix[userId] ??= {};
|
|
251
311
|
}
|
|
312
|
+
this.pendingListener ??= this.sentListener;
|
|
313
|
+
this.pendingOptions ??= this.sentOptions;
|
|
314
|
+
for (const [userId, position] of Object.entries(this.sentPositions)) {
|
|
315
|
+
if (!(userId in this.pendingPositions))
|
|
316
|
+
this.pendingPositions[userId] = position;
|
|
317
|
+
}
|
|
252
318
|
this.scheduleMix();
|
|
253
319
|
}
|
|
254
320
|
}
|
|
@@ -265,8 +331,14 @@ export class MultiplayerVoiceImpl {
|
|
|
265
331
|
for (const callback of this.speakers)
|
|
266
332
|
callListener(callback, playerIds.slice());
|
|
267
333
|
}
|
|
334
|
+
hasPending() {
|
|
335
|
+
return (Object.keys(this.pendingMix).length > 0 ||
|
|
336
|
+
Object.keys(this.pendingPositions).length > 0 ||
|
|
337
|
+
this.pendingListener !== null ||
|
|
338
|
+
this.pendingOptions !== null);
|
|
339
|
+
}
|
|
268
340
|
scheduleMix() {
|
|
269
|
-
if (this.mixTimer !== null ||
|
|
341
|
+
if (this.mixTimer !== null || !this.hasPending())
|
|
270
342
|
return;
|
|
271
343
|
const wait = Math.max(0, this.lastMixSentAt + VOICE_MIX_INTERVAL_MS - Date.now());
|
|
272
344
|
this.mixTimer = setTimeout(() => {
|
|
@@ -290,11 +362,54 @@ export class MultiplayerVoiceImpl {
|
|
|
290
362
|
mix[userId] = full;
|
|
291
363
|
}
|
|
292
364
|
this.pendingMix = {};
|
|
293
|
-
|
|
365
|
+
const spatial = this.takeSpatial(userIds);
|
|
366
|
+
if (Object.keys(mix).length === 0 && !spatial)
|
|
294
367
|
return;
|
|
295
368
|
this.lastMixSentAt = Date.now();
|
|
296
|
-
|
|
369
|
+
if (Object.keys(mix).length > 0) {
|
|
370
|
+
void hostRequest("voice.setMix", { sessionId: this.voiceSessionId, mix }).catch(() => { });
|
|
371
|
+
}
|
|
372
|
+
if (spatial) {
|
|
373
|
+
void hostRequest("voice.setSpatial", { sessionId: this.voiceSessionId, ...spatial }).catch(() => { });
|
|
374
|
+
}
|
|
297
375
|
}
|
|
376
|
+
takeSpatial(userIds) {
|
|
377
|
+
const update = {};
|
|
378
|
+
if (this.pendingListener) {
|
|
379
|
+
update.listener = this.pendingListener;
|
|
380
|
+
this.sentListener = this.pendingListener;
|
|
381
|
+
}
|
|
382
|
+
if (this.pendingOptions) {
|
|
383
|
+
update.options = this.pendingOptions;
|
|
384
|
+
this.sentOptions = this.pendingOptions;
|
|
385
|
+
}
|
|
386
|
+
const positions = {};
|
|
387
|
+
for (const [userId, position] of Object.entries(this.pendingPositions)) {
|
|
388
|
+
if (!userIds.has(userId))
|
|
389
|
+
continue;
|
|
390
|
+
positions[userId] = position;
|
|
391
|
+
if (position)
|
|
392
|
+
this.sentPositions[userId] = position;
|
|
393
|
+
else
|
|
394
|
+
delete this.sentPositions[userId];
|
|
395
|
+
}
|
|
396
|
+
if (Object.keys(positions).length > 0)
|
|
397
|
+
update.positions = positions;
|
|
398
|
+
this.pendingListener = null;
|
|
399
|
+
this.pendingOptions = null;
|
|
400
|
+
this.pendingPositions = {};
|
|
401
|
+
return update.listener || update.options || update.positions ? update : null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function vector(value) {
|
|
405
|
+
return Array.isArray(value) &&
|
|
406
|
+
value.length === 3 &&
|
|
407
|
+
value.every((component) => typeof component === "number" && Number.isFinite(component))
|
|
408
|
+
? [value[0], value[1], value[2]]
|
|
409
|
+
: null;
|
|
410
|
+
}
|
|
411
|
+
function isZero(value) {
|
|
412
|
+
return value[0] === 0 && value[1] === 0 && value[2] === 0;
|
|
298
413
|
}
|
|
299
414
|
function clamp(value, min, max) {
|
|
300
415
|
return Math.min(max, Math.max(min, value));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Json } from "../types";
|
|
1
2
|
export type MultiplayerSessionMode = "named" | "party" | "public";
|
|
2
3
|
export interface PlayerIdentity {
|
|
3
4
|
userId: string;
|
|
@@ -11,6 +12,15 @@ export interface PlatformSessionConfig {
|
|
|
11
12
|
readonly mode: MultiplayerSessionMode;
|
|
12
13
|
readonly tickRate: number;
|
|
13
14
|
readonly maxPlayers: number;
|
|
15
|
+
readonly world?: PlatformWorldSnapshot;
|
|
16
|
+
}
|
|
17
|
+
export interface PlatformWorldCheckpoint {
|
|
18
|
+
version: number;
|
|
19
|
+
value: Json;
|
|
20
|
+
}
|
|
21
|
+
export interface PlatformWorldSnapshot {
|
|
22
|
+
revision: string;
|
|
23
|
+
checkpoint: PlatformWorldCheckpoint | null;
|
|
14
24
|
}
|
|
15
25
|
export interface PlatformHandle {
|
|
16
26
|
send(connId: string, text: string): void;
|
|
@@ -34,5 +44,8 @@ export interface PlatformHandle {
|
|
|
34
44
|
storage?(op: string, argsJson: string): Promise<string>;
|
|
35
45
|
/** False when the host cannot broker storage, so a call can fail at once. */
|
|
36
46
|
readonly storageEnabled?: boolean;
|
|
47
|
+
/** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
|
|
48
|
+
worldCheckpointRead?(): Promise<string>;
|
|
49
|
+
worldCheckpointCommit?(argsJson: string): Promise<string>;
|
|
37
50
|
readonly config: PlatformSessionConfig;
|
|
38
51
|
}
|
package/dist/server/wrapper.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MultiplayerSessionMode, PlatformHandle, PlayerIdentity } from "./platformHandle";
|
|
2
|
+
import type { Json } from "../types";
|
|
2
3
|
import { type ServerStorage } from "./storage";
|
|
3
|
-
export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, PlayerIdentity, } from "./platformHandle";
|
|
4
|
+
export type { MultiplayerSessionMode, PlatformHandle, PlatformSessionConfig, PlatformWorldCheckpoint, PlatformWorldSnapshot, PlayerIdentity, } from "./platformHandle";
|
|
4
5
|
export type { CharacterKnowledgeSource, CompleteCharacterBeatInput, DeliverCharacterKnowledgeInput, RecordCharacterEventInput, SetCharacterGameStateInput, ShareCharacterClaimInput, ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerPlaythrough, ServerPlaythroughCharacters, ServerCharacterHistoryOptions, ServerStorageScope, } from "./storage";
|
|
5
6
|
export interface MultiplayerServerPlayer extends PlayerIdentity {
|
|
6
7
|
playerId: string;
|
|
@@ -18,12 +19,40 @@ export interface MultiplayerServerSession {
|
|
|
18
19
|
* when its last player leaves and its memory goes with it.
|
|
19
20
|
*/
|
|
20
21
|
readonly storage: ServerStorage;
|
|
22
|
+
/** A durable checkpoint for an opted-in persistent destination. */
|
|
23
|
+
readonly world: MultiplayerServerWorld | null;
|
|
21
24
|
/** Sends bytes as a binary message and all other payloads as JSON. */
|
|
22
25
|
send(playerId: string, data: unknown): void;
|
|
23
26
|
/** Broadcasts bytes as a binary message and all other payloads as JSON. */
|
|
24
27
|
broadcast(data: unknown, options?: MultiplayerBroadcastOptions): void;
|
|
25
28
|
disconnect(playerId: string, reason?: string): void;
|
|
26
29
|
}
|
|
30
|
+
export interface MultiplayerWorldCheckpoint {
|
|
31
|
+
version: number;
|
|
32
|
+
value: Json;
|
|
33
|
+
}
|
|
34
|
+
export interface MultiplayerWorldSnapshot {
|
|
35
|
+
revision: string;
|
|
36
|
+
checkpoint: MultiplayerWorldCheckpoint | null;
|
|
37
|
+
}
|
|
38
|
+
export interface MultiplayerWorldCommitInput {
|
|
39
|
+
expectedRevision: string;
|
|
40
|
+
operationId: string;
|
|
41
|
+
checkpoint: MultiplayerWorldCheckpoint;
|
|
42
|
+
}
|
|
43
|
+
export interface MultiplayerWorldCommitReceipt {
|
|
44
|
+
revision: string;
|
|
45
|
+
replayed: boolean;
|
|
46
|
+
operationId: string;
|
|
47
|
+
}
|
|
48
|
+
export interface MultiplayerServerWorld {
|
|
49
|
+
/** The checkpoint acquired before this runtime started. */
|
|
50
|
+
readonly snapshot: MultiplayerWorldSnapshot;
|
|
51
|
+
/** Reads the latest durable checkpoint, for example after a revision conflict. */
|
|
52
|
+
read(): Promise<MultiplayerWorldSnapshot>;
|
|
53
|
+
/** Commits exactly against expectedRevision; conflicts never partially write. */
|
|
54
|
+
commit(input: MultiplayerWorldCommitInput): Promise<MultiplayerWorldCommitReceipt>;
|
|
55
|
+
}
|
|
27
56
|
type CallbackResult = unknown;
|
|
28
57
|
export interface MultiplayerServerCallbacks {
|
|
29
58
|
onStart?: (session: MultiplayerServerSession) => CallbackResult;
|
package/dist/server/wrapper.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isBinaryPayload, toUint8Array, } from "../multiplayerProtocol";
|
|
2
|
+
import { encodeStorageValue } from "../storageJson";
|
|
2
3
|
import { createServerStorage } from "./storage";
|
|
3
4
|
// The publish bundler binds the author's default callbacks export once and
|
|
4
5
|
// exports the returned one-argument function as `__castleBootSession`.
|
|
@@ -68,6 +69,7 @@ function createAuthorSession(platformHandle, players) {
|
|
|
68
69
|
deckId: platformHandle.config.deckId,
|
|
69
70
|
mode: platformHandle.config.mode,
|
|
70
71
|
storage: createServerStorage(platformHandle),
|
|
72
|
+
world: createServerWorld(platformHandle),
|
|
71
73
|
send(playerId, data) {
|
|
72
74
|
if (isBinaryPayload(data)) {
|
|
73
75
|
requireSendBinary(platformHandle);
|
|
@@ -106,6 +108,96 @@ function createAuthorSession(platformHandle, players) {
|
|
|
106
108
|
},
|
|
107
109
|
};
|
|
108
110
|
}
|
|
111
|
+
const WORLD_OPERATION_ID_MAX_BYTES = 128;
|
|
112
|
+
const WORLD_OPERATION_ID_MAX_CHARACTERS = 120;
|
|
113
|
+
const WORLD_CHECKPOINT_VERSION_MAX = 2_147_483_647;
|
|
114
|
+
function createServerWorld(platformHandle) {
|
|
115
|
+
if (!platformHandle.config.world)
|
|
116
|
+
return null;
|
|
117
|
+
if (typeof platformHandle.worldCheckpointRead !== "function" ||
|
|
118
|
+
typeof platformHandle.worldCheckpointCommit !== "function") {
|
|
119
|
+
throw worldError("The persistent-world checkpoint broker is unavailable.", "read");
|
|
120
|
+
}
|
|
121
|
+
const initial = parseWorldSnapshot(platformHandle.config.world, "read");
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
snapshot: initial,
|
|
124
|
+
async read() {
|
|
125
|
+
const result = await platformHandle.worldCheckpointRead();
|
|
126
|
+
return parseWorldSnapshot(parsePlatformJson(result, "read"), "read");
|
|
127
|
+
},
|
|
128
|
+
async commit(input) {
|
|
129
|
+
const expectedRevision = assertWorldRevision(input?.expectedRevision);
|
|
130
|
+
const operationId = assertWorldOperationId(input?.operationId);
|
|
131
|
+
const checkpoint = assertWorldCheckpoint(input?.checkpoint);
|
|
132
|
+
const raw = await platformHandle.worldCheckpointCommit(JSON.stringify({
|
|
133
|
+
expectedRevision,
|
|
134
|
+
operationId,
|
|
135
|
+
checkpoint,
|
|
136
|
+
}));
|
|
137
|
+
const result = parsePlatformJson(raw, "commit");
|
|
138
|
+
if (typeof result.replayed !== "boolean") {
|
|
139
|
+
throw worldError("The platform returned an invalid commit receipt.", "commit");
|
|
140
|
+
}
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
revision: assertWorldRevision(result.revision),
|
|
143
|
+
replayed: result.replayed,
|
|
144
|
+
operationId,
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function parseWorldSnapshot(value, operation) {
|
|
150
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
151
|
+
throw worldError("The platform returned an invalid world checkpoint.", "read");
|
|
152
|
+
}
|
|
153
|
+
const raw = value;
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
revision: assertWorldRevision(raw.revision),
|
|
156
|
+
checkpoint: raw.checkpoint === null
|
|
157
|
+
? null
|
|
158
|
+
: assertWorldCheckpoint(raw.checkpoint, operation),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function assertWorldCheckpoint(value, operation = "commit") {
|
|
162
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
163
|
+
throw worldError("A world checkpoint requires version and value.", operation);
|
|
164
|
+
}
|
|
165
|
+
const raw = value;
|
|
166
|
+
if (!Number.isSafeInteger(raw.version) ||
|
|
167
|
+
raw.version < 0 ||
|
|
168
|
+
raw.version > WORLD_CHECKPOINT_VERSION_MAX) {
|
|
169
|
+
throw worldError(`A world checkpoint version must be an integer from 0 through ${WORLD_CHECKPOINT_VERSION_MAX}.`, operation);
|
|
170
|
+
}
|
|
171
|
+
const text = encodeStorageValue(raw.value, `session.world.${operation}`);
|
|
172
|
+
const normalized = JSON.parse(text);
|
|
173
|
+
return Object.freeze({ version: raw.version, value: normalized });
|
|
174
|
+
}
|
|
175
|
+
function assertWorldRevision(value) {
|
|
176
|
+
if (typeof value === "string" && /^(?:0|[1-9][0-9]*)$/.test(value))
|
|
177
|
+
return value;
|
|
178
|
+
throw worldError("A world checkpoint revision must be a canonical decimal string.", "commit");
|
|
179
|
+
}
|
|
180
|
+
function assertWorldOperationId(value) {
|
|
181
|
+
if (typeof value !== "string" ||
|
|
182
|
+
value.length === 0 ||
|
|
183
|
+
value.length > WORLD_OPERATION_ID_MAX_CHARACTERS ||
|
|
184
|
+
value.includes("\0") ||
|
|
185
|
+
new TextEncoder().encode(value).byteLength > WORLD_OPERATION_ID_MAX_BYTES) {
|
|
186
|
+
throw worldError(`A world checkpoint operation id must be at most ${WORLD_OPERATION_ID_MAX_CHARACTERS} characters and ${WORLD_OPERATION_ID_MAX_BYTES} bytes.`, "commit");
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
function parsePlatformJson(value, operation) {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(value);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
throw worldError("The platform returned an invalid world checkpoint response.", operation);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function worldError(message, operation) {
|
|
199
|
+
return new TypeError(`session.world.${operation}: ${message}`);
|
|
200
|
+
}
|
|
109
201
|
function requireSendBinary(platformHandle) {
|
|
110
202
|
if (typeof platformHandle.sendBinary !== "function")
|
|
111
203
|
unsupportedBinaryRuntime();
|
package/dist/transport.js
CHANGED