castle-web-sdk 0.4.35 → 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/README.md +70 -7
- package/dist/castle.d.ts +3 -2
- package/dist/commands.d.ts +93 -0
- package/dist/commands.js +25 -0
- package/dist/multiplayer.d.ts +16 -1
- package/dist/multiplayer.js +166 -11
- package/dist/multiplayerVoice.d.ts +91 -0
- package/dist/multiplayerVoice.js +424 -0
- 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.d.ts +2 -1
- package/dist/transport.js +16 -1
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { VoiceEvent, VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector } from "./commands";
|
|
2
|
+
import type { MultiplayerPlayer } from "./multiplayerProtocol";
|
|
3
|
+
export declare const VOICE_MIX_INTERVAL_MS = 100;
|
|
4
|
+
export type { VoiceJoinStatus, VoiceListener, VoiceMixEntry, VoiceSpatialOptions, VoiceState, VoiceVector, };
|
|
5
|
+
export interface MultiplayerVoice {
|
|
6
|
+
/** "unavailable" when this host, deck, session or player can't have voice. */
|
|
7
|
+
readonly state: VoiceState;
|
|
8
|
+
/**
|
|
9
|
+
* Asks the player to join voice. The host shows its own prompt; nothing happens
|
|
10
|
+
* without the player's say-so. Resolves once they have chosen.
|
|
11
|
+
*/
|
|
12
|
+
requestJoin(): Promise<VoiceJoinStatus>;
|
|
13
|
+
/** Leaves voice, or withdraws a pending request. The game connection is unaffected. */
|
|
14
|
+
leave(): void;
|
|
15
|
+
/**
|
|
16
|
+
* Suggested volume (0-1) and stereo position (-1 left to 1 right) per player,
|
|
17
|
+
* keyed by playerId. Players and fields not named keep their last value (initially
|
|
18
|
+
* full volume, centered). Sent at most ten times a second; the latest values win.
|
|
19
|
+
*/
|
|
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;
|
|
34
|
+
onState(callback: (state: VoiceState) => void): () => void;
|
|
35
|
+
/** The playerIds speaking right now; called whenever that set changes. */
|
|
36
|
+
onSpeaking(callback: (playerIds: string[]) => void): () => void;
|
|
37
|
+
}
|
|
38
|
+
export interface VoiceSessionAccess {
|
|
39
|
+
sessionId(): string;
|
|
40
|
+
players(): MultiplayerPlayer[];
|
|
41
|
+
isSolo(): boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
|
|
44
|
+
private readonly access;
|
|
45
|
+
private currentState;
|
|
46
|
+
private voiceSessionId;
|
|
47
|
+
private attaching;
|
|
48
|
+
private hostStateVersion;
|
|
49
|
+
private pendingJoin;
|
|
50
|
+
private joinRequest;
|
|
51
|
+
private speakingUserIds;
|
|
52
|
+
private speaking;
|
|
53
|
+
private pendingMix;
|
|
54
|
+
private sentMix;
|
|
55
|
+
private pendingListener;
|
|
56
|
+
private pendingPositions;
|
|
57
|
+
private pendingOptions;
|
|
58
|
+
private sentListener;
|
|
59
|
+
private sentPositions;
|
|
60
|
+
private sentOptions;
|
|
61
|
+
private mixTimer;
|
|
62
|
+
private lastMixSentAt;
|
|
63
|
+
private disposed;
|
|
64
|
+
private readonly states;
|
|
65
|
+
private readonly speakers;
|
|
66
|
+
constructor(access: VoiceSessionAccess);
|
|
67
|
+
get state(): VoiceState;
|
|
68
|
+
attach(): Promise<void>;
|
|
69
|
+
requestJoin(): Promise<VoiceJoinStatus>;
|
|
70
|
+
private askToJoin;
|
|
71
|
+
leave(): void;
|
|
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;
|
|
76
|
+
onState(callback: (state: VoiceState) => void): () => void;
|
|
77
|
+
onSpeaking(callback: (playerIds: string[]) => void): () => void;
|
|
78
|
+
receive(event: VoiceEvent): void;
|
|
79
|
+
rosterChanged(): void;
|
|
80
|
+
dispose(): void;
|
|
81
|
+
private isCurrent;
|
|
82
|
+
private withdraw;
|
|
83
|
+
private endSession;
|
|
84
|
+
private sendLeave;
|
|
85
|
+
private setState;
|
|
86
|
+
private emitSpeaking;
|
|
87
|
+
private hasPending;
|
|
88
|
+
private scheduleMix;
|
|
89
|
+
private flushMix;
|
|
90
|
+
private takeSpatial;
|
|
91
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { hostRequest, setVoiceEventHandler } from "./transport";
|
|
2
|
+
// Voice chat in a multiplayer session. The host (castle.xyz or the Castle app) owns
|
|
3
|
+
// the microphone, the call and playback, and shows its own prompt and live-mic badge; a
|
|
4
|
+
// deck never touches audio. What a deck gets: whether voice is on, a way to ask the
|
|
5
|
+
// player to join, who is speaking, and a suggested volume and stereo position per
|
|
6
|
+
// player (proximity chat). Push-to-talk and muting are the player's, in the host's UI.
|
|
7
|
+
// A deck driving proximity every frame sends far more than the host needs.
|
|
8
|
+
export const VOICE_MIX_INTERVAL_MS = 100;
|
|
9
|
+
const DEFAULT_MIX = { gain: 1, pan: 0 };
|
|
10
|
+
let activeVoice = null;
|
|
11
|
+
function dispatchVoiceEvent(event) {
|
|
12
|
+
activeVoice?.receive(event);
|
|
13
|
+
}
|
|
14
|
+
// One multiplayer connection is live at a time, so one voice receives host events.
|
|
15
|
+
function setActiveVoice(voice, current) {
|
|
16
|
+
if (current && activeVoice !== current)
|
|
17
|
+
return;
|
|
18
|
+
activeVoice = voice;
|
|
19
|
+
}
|
|
20
|
+
export class MultiplayerVoiceImpl {
|
|
21
|
+
access;
|
|
22
|
+
currentState = "unavailable";
|
|
23
|
+
// The session this voice state belongs to; "" before the first attach or in solo.
|
|
24
|
+
voiceSessionId = "";
|
|
25
|
+
attaching = null;
|
|
26
|
+
// Bumped on every host state event, so a join reply never overrides a newer one.
|
|
27
|
+
hostStateVersion = 0;
|
|
28
|
+
pendingJoin = null;
|
|
29
|
+
joinRequest = null;
|
|
30
|
+
speakingUserIds = [];
|
|
31
|
+
speaking = [];
|
|
32
|
+
pendingMix = {};
|
|
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;
|
|
41
|
+
mixTimer = null;
|
|
42
|
+
lastMixSentAt = 0;
|
|
43
|
+
disposed = false;
|
|
44
|
+
states = new Set();
|
|
45
|
+
speakers = new Set();
|
|
46
|
+
constructor(access) {
|
|
47
|
+
this.access = access;
|
|
48
|
+
}
|
|
49
|
+
get state() {
|
|
50
|
+
return this.currentState;
|
|
51
|
+
}
|
|
52
|
+
// Called by the connection whenever it (re)gains a session. A reconnect keeps the
|
|
53
|
+
// session; a re-acquisition may land in a different one, or in solo, and voice for
|
|
54
|
+
// the old session ends with it. Asks the host where voice stands; a host without
|
|
55
|
+
// voice (an older app, local dev) answers with an error or "unavailable".
|
|
56
|
+
attach() {
|
|
57
|
+
if (this.disposed)
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
setActiveVoice(this);
|
|
60
|
+
setVoiceEventHandler(dispatchVoiceEvent);
|
|
61
|
+
const sessionId = this.access.isSolo() ? "" : this.access.sessionId();
|
|
62
|
+
if (this.voiceSessionId !== sessionId) {
|
|
63
|
+
this.endSession();
|
|
64
|
+
// The old session's state means nothing for the new one until the host answers.
|
|
65
|
+
this.setState("unavailable");
|
|
66
|
+
}
|
|
67
|
+
this.voiceSessionId = sessionId;
|
|
68
|
+
if (!sessionId) {
|
|
69
|
+
this.setState("unavailable");
|
|
70
|
+
this.attaching = null;
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
const attaching = hostRequest("voice.getState", { sessionId }).then((result) => {
|
|
74
|
+
if (this.isCurrent(sessionId))
|
|
75
|
+
this.setState(result.state);
|
|
76
|
+
}, () => {
|
|
77
|
+
if (this.isCurrent(sessionId))
|
|
78
|
+
this.setState("unavailable");
|
|
79
|
+
});
|
|
80
|
+
this.attaching = attaching;
|
|
81
|
+
return attaching;
|
|
82
|
+
}
|
|
83
|
+
// A second call while the player is still choosing shares the first one's answer.
|
|
84
|
+
requestJoin() {
|
|
85
|
+
if (!this.joinRequest) {
|
|
86
|
+
const request = this.askToJoin().finally(() => {
|
|
87
|
+
if (this.joinRequest === request)
|
|
88
|
+
this.joinRequest = null;
|
|
89
|
+
});
|
|
90
|
+
this.joinRequest = request;
|
|
91
|
+
}
|
|
92
|
+
return this.joinRequest;
|
|
93
|
+
}
|
|
94
|
+
async askToJoin() {
|
|
95
|
+
// Registered before any waiting, so leave() or a session change can withdraw a
|
|
96
|
+
// request that hasn't reached the host yet.
|
|
97
|
+
const pending = new AbortController();
|
|
98
|
+
this.pendingJoin = pending;
|
|
99
|
+
// `join()` resolves at hello, before the host has said whether voice is on. A
|
|
100
|
+
// reconnect can attach again meanwhile; wait for the latest answer.
|
|
101
|
+
let awaited = null;
|
|
102
|
+
while (this.attaching && this.attaching !== awaited) {
|
|
103
|
+
awaited = this.attaching;
|
|
104
|
+
await awaited;
|
|
105
|
+
}
|
|
106
|
+
if (pending.signal.aborted ||
|
|
107
|
+
this.disposed ||
|
|
108
|
+
!this.voiceSessionId ||
|
|
109
|
+
this.currentState === "unavailable" ||
|
|
110
|
+
this.currentState === "live") {
|
|
111
|
+
if (this.pendingJoin === pending)
|
|
112
|
+
this.pendingJoin = null;
|
|
113
|
+
if (pending.signal.aborted || this.currentState !== "live")
|
|
114
|
+
return "unavailable";
|
|
115
|
+
return "joined";
|
|
116
|
+
}
|
|
117
|
+
const sessionId = this.voiceSessionId;
|
|
118
|
+
const version = this.hostStateVersion;
|
|
119
|
+
this.setState("joining");
|
|
120
|
+
let status;
|
|
121
|
+
try {
|
|
122
|
+
({ status } = await hostRequest("voice.requestJoin", { sessionId }, { signal: pending.signal }));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
status = "unavailable";
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if (this.pendingJoin === pending)
|
|
129
|
+
this.pendingJoin = null;
|
|
130
|
+
}
|
|
131
|
+
if (pending.signal.aborted || !this.isCurrent(sessionId)) {
|
|
132
|
+
// Withdrawn, or the game connection ended, while the player was choosing. The
|
|
133
|
+
// abort already cancelled the request at the host, whose executor undoes a
|
|
134
|
+
// "yes" that lands anyway.
|
|
135
|
+
return "unavailable";
|
|
136
|
+
}
|
|
137
|
+
if (version === this.hostStateVersion) {
|
|
138
|
+
this.setState(status === "joined" ? "live" : status === "unavailable" ? "unavailable" : "off");
|
|
139
|
+
}
|
|
140
|
+
return status;
|
|
141
|
+
}
|
|
142
|
+
leave() {
|
|
143
|
+
if (this.disposed)
|
|
144
|
+
return;
|
|
145
|
+
const inVoice = this.currentState === "live" || this.currentState === "joining";
|
|
146
|
+
if (!inVoice && this.pendingJoin === null)
|
|
147
|
+
return;
|
|
148
|
+
this.withdraw();
|
|
149
|
+
if (inVoice) {
|
|
150
|
+
this.setState("off");
|
|
151
|
+
this.sendLeave(this.voiceSessionId);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
setMix(mix) {
|
|
155
|
+
if (this.disposed || !mix || typeof mix !== "object")
|
|
156
|
+
return;
|
|
157
|
+
const players = this.access.players();
|
|
158
|
+
for (const [playerId, entry] of Object.entries(mix)) {
|
|
159
|
+
const player = players.find((candidate) => candidate.playerId === playerId);
|
|
160
|
+
if (!player || !entry || typeof entry !== "object")
|
|
161
|
+
continue;
|
|
162
|
+
const next = { ...this.pendingMix[player.userId] };
|
|
163
|
+
if (typeof entry.gain === "number" && Number.isFinite(entry.gain)) {
|
|
164
|
+
next.gain = clamp(entry.gain, 0, 1);
|
|
165
|
+
}
|
|
166
|
+
if (typeof entry.pan === "number" && Number.isFinite(entry.pan)) {
|
|
167
|
+
next.pan = clamp(entry.pan, -1, 1);
|
|
168
|
+
}
|
|
169
|
+
this.pendingMix[player.userId] = next;
|
|
170
|
+
}
|
|
171
|
+
this.scheduleMix();
|
|
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
|
+
}
|
|
212
|
+
onState(callback) {
|
|
213
|
+
this.states.add(callback);
|
|
214
|
+
callListener(callback, this.currentState);
|
|
215
|
+
return () => this.states.delete(callback);
|
|
216
|
+
}
|
|
217
|
+
onSpeaking(callback) {
|
|
218
|
+
this.speakers.add(callback);
|
|
219
|
+
callListener(callback, this.speaking.slice());
|
|
220
|
+
return () => this.speakers.delete(callback);
|
|
221
|
+
}
|
|
222
|
+
receive(event) {
|
|
223
|
+
if (this.disposed || !this.voiceSessionId || event.sessionId !== this.voiceSessionId) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (event.type === "state") {
|
|
227
|
+
this.hostStateVersion += 1;
|
|
228
|
+
this.setState(event.state);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.speakingUserIds = event.userIds.slice();
|
|
232
|
+
this.emitSpeaking();
|
|
233
|
+
}
|
|
234
|
+
// The roster changed: speakers are re-mapped, and mixes for players who left dropped.
|
|
235
|
+
rosterChanged() {
|
|
236
|
+
if (this.disposed)
|
|
237
|
+
return;
|
|
238
|
+
const userIds = new Set(this.access.players().map((player) => player.userId));
|
|
239
|
+
for (const userId of Object.keys(this.pendingMix)) {
|
|
240
|
+
if (!userIds.has(userId))
|
|
241
|
+
delete this.pendingMix[userId];
|
|
242
|
+
}
|
|
243
|
+
for (const userId of Object.keys(this.sentMix)) {
|
|
244
|
+
if (!userIds.has(userId))
|
|
245
|
+
delete this.sentMix[userId];
|
|
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
|
+
}
|
|
255
|
+
this.emitSpeaking();
|
|
256
|
+
}
|
|
257
|
+
// The game connection ended: voice for its session goes with it.
|
|
258
|
+
dispose() {
|
|
259
|
+
if (this.disposed)
|
|
260
|
+
return;
|
|
261
|
+
this.endSession();
|
|
262
|
+
this.setState("unavailable");
|
|
263
|
+
this.disposed = true;
|
|
264
|
+
if (this.mixTimer !== null)
|
|
265
|
+
clearTimeout(this.mixTimer);
|
|
266
|
+
this.mixTimer = null;
|
|
267
|
+
setActiveVoice(null, this);
|
|
268
|
+
}
|
|
269
|
+
isCurrent(sessionId) {
|
|
270
|
+
return !this.disposed && sessionId !== "" && sessionId === this.voiceSessionId;
|
|
271
|
+
}
|
|
272
|
+
// Cancels a pending request, so a retry starts a fresh prompt.
|
|
273
|
+
withdraw() {
|
|
274
|
+
this.pendingJoin?.abort();
|
|
275
|
+
this.pendingJoin = null;
|
|
276
|
+
this.joinRequest = null;
|
|
277
|
+
}
|
|
278
|
+
// Leaves the current voice session, if the player is in it or being asked.
|
|
279
|
+
endSession() {
|
|
280
|
+
const inVoice = this.currentState === "live" || this.currentState === "joining" || this.pendingJoin !== null;
|
|
281
|
+
this.withdraw();
|
|
282
|
+
if (inVoice && this.voiceSessionId)
|
|
283
|
+
this.sendLeave(this.voiceSessionId);
|
|
284
|
+
this.speakingUserIds = [];
|
|
285
|
+
this.pendingMix = {};
|
|
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;
|
|
293
|
+
this.emitSpeaking();
|
|
294
|
+
}
|
|
295
|
+
sendLeave(sessionId) {
|
|
296
|
+
void hostRequest("voice.leave", { sessionId }).catch(() => { });
|
|
297
|
+
}
|
|
298
|
+
setState(state) {
|
|
299
|
+
if (this.currentState === state)
|
|
300
|
+
return;
|
|
301
|
+
this.currentState = state;
|
|
302
|
+
if (state !== "live")
|
|
303
|
+
this.speakingUserIds = [];
|
|
304
|
+
this.emitSpeaking();
|
|
305
|
+
for (const callback of this.states)
|
|
306
|
+
callListener(callback, state);
|
|
307
|
+
if (state === "live") {
|
|
308
|
+
// A rejoined call starts from scratch at the host: send the deck's mix again.
|
|
309
|
+
for (const userId of Object.keys(this.sentMix)) {
|
|
310
|
+
this.pendingMix[userId] ??= {};
|
|
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
|
+
}
|
|
318
|
+
this.scheduleMix();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
emitSpeaking() {
|
|
322
|
+
const userIds = new Set(this.speakingUserIds);
|
|
323
|
+
const playerIds = this.access
|
|
324
|
+
.players()
|
|
325
|
+
.filter((player) => userIds.has(player.userId))
|
|
326
|
+
.map((player) => player.playerId)
|
|
327
|
+
.sort();
|
|
328
|
+
if (playerIds.join("\n") === this.speaking.join("\n"))
|
|
329
|
+
return;
|
|
330
|
+
this.speaking = playerIds;
|
|
331
|
+
for (const callback of this.speakers)
|
|
332
|
+
callListener(callback, playerIds.slice());
|
|
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
|
+
}
|
|
340
|
+
scheduleMix() {
|
|
341
|
+
if (this.mixTimer !== null || !this.hasPending())
|
|
342
|
+
return;
|
|
343
|
+
const wait = Math.max(0, this.lastMixSentAt + VOICE_MIX_INTERVAL_MS - Date.now());
|
|
344
|
+
this.mixTimer = setTimeout(() => {
|
|
345
|
+
this.mixTimer = null;
|
|
346
|
+
this.flushMix();
|
|
347
|
+
}, wait);
|
|
348
|
+
}
|
|
349
|
+
// Held until the player is in voice, then sent: the latest mix is what matters. Each
|
|
350
|
+
// entry goes out whole, so a field the deck didn't name keeps its last value.
|
|
351
|
+
flushMix() {
|
|
352
|
+
if (this.disposed || !this.voiceSessionId || this.currentState !== "live")
|
|
353
|
+
return;
|
|
354
|
+
const userIds = new Set(this.access.players().map((player) => player.userId));
|
|
355
|
+
const mix = {};
|
|
356
|
+
for (const [userId, entry] of Object.entries(this.pendingMix)) {
|
|
357
|
+
if (!userIds.has(userId))
|
|
358
|
+
continue;
|
|
359
|
+
const previous = this.sentMix[userId] ?? DEFAULT_MIX;
|
|
360
|
+
const full = { gain: entry.gain ?? previous.gain, pan: entry.pan ?? previous.pan };
|
|
361
|
+
this.sentMix[userId] = full;
|
|
362
|
+
mix[userId] = full;
|
|
363
|
+
}
|
|
364
|
+
this.pendingMix = {};
|
|
365
|
+
const spatial = this.takeSpatial(userIds);
|
|
366
|
+
if (Object.keys(mix).length === 0 && !spatial)
|
|
367
|
+
return;
|
|
368
|
+
this.lastMixSentAt = Date.now();
|
|
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
|
+
}
|
|
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;
|
|
413
|
+
}
|
|
414
|
+
function clamp(value, min, max) {
|
|
415
|
+
return Math.min(max, Math.max(min, value));
|
|
416
|
+
}
|
|
417
|
+
function callListener(callback, value) {
|
|
418
|
+
try {
|
|
419
|
+
callback(value);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// One deck listener must not break voice or other listeners.
|
|
423
|
+
}
|
|
424
|
+
}
|
|
@@ -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;
|