castle-web-sdk 0.4.34 → 0.4.36
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 +201 -7
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +70 -0
- package/dist/castle.d.ts +5 -0
- package/dist/castle.js +2 -0
- package/dist/commands.d.ts +154 -0
- package/dist/commands.js +46 -0
- package/dist/decks.d.ts +7 -0
- package/dist/decks.js +31 -0
- package/dist/multiplayer.d.ts +3 -0
- package/dist/multiplayer.js +13 -0
- package/dist/multiplayerVoice.d.ts +67 -0
- package/dist/multiplayerVoice.js +309 -0
- package/dist/runtime.d.ts +4 -2
- package/dist/runtime.js +51 -40
- package/dist/server/storage.d.ts +44 -0
- package/dist/server/storage.js +54 -0
- package/dist/transport.d.ts +7 -2
- package/dist/transport.js +120 -23
- package/package.json +1 -1
package/dist/decks.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Decks — the player's own published decks and the decks they saved, for
|
|
2
|
+
// picking where a portal leads. Each entry has the same shape as Portal.info:
|
|
3
|
+
// deckId, title, creator and a cover as a data: url.
|
|
4
|
+
//
|
|
5
|
+
// Both lists leave out decks a portal cannot reach: a private deck, or one
|
|
6
|
+
// whose creator turned portals off.
|
|
7
|
+
//
|
|
8
|
+
// Decks is limited to Castle admins for now; other players get a
|
|
9
|
+
// PERMISSION_DENIED error.
|
|
10
|
+
import { CastleError } from "./errors";
|
|
11
|
+
import { hostRequest } from "./transport";
|
|
12
|
+
function mine(options = {}) {
|
|
13
|
+
return hostRequest("decks.mine", { limit: listLimit(options.limit, "Decks.mine") });
|
|
14
|
+
}
|
|
15
|
+
function saved(options = {}) {
|
|
16
|
+
return hostRequest("decks.saved", { limit: listLimit(options.limit, "Decks.saved") });
|
|
17
|
+
}
|
|
18
|
+
function listLimit(value, operation) {
|
|
19
|
+
if (value === undefined || value === null) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (Number.isSafeInteger(value) && value >= 1 && value <= 50) {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
throw new CastleError({
|
|
26
|
+
code: "INVALID_ARGUMENT",
|
|
27
|
+
message: `${operation} limit must be an integer from 1 through 50.`,
|
|
28
|
+
operation,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export const Decks = Object.freeze({ mine, saved });
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MultiplayerSoloReason } from "./commands";
|
|
2
2
|
import { CastleError } from "./errors";
|
|
3
3
|
import { type MultiplayerPlayer } from "./multiplayerProtocol";
|
|
4
|
+
import { type MultiplayerVoice } from "./multiplayerVoice";
|
|
4
5
|
export type MultiplayerJoinOptions = {
|
|
5
6
|
mode: "named";
|
|
6
7
|
key: string;
|
|
@@ -43,6 +44,8 @@ export interface MultiplayerConnection {
|
|
|
43
44
|
onMessage(callback: (data: unknown) => void): () => void;
|
|
44
45
|
onRoster(callback: (roster: MultiplayerRoster) => void): () => void;
|
|
45
46
|
onStateChange(callback: (state: MultiplayerConnectionState, metadata?: MultiplayerStateMetadata) => void): () => void;
|
|
47
|
+
/** Voice chat for this session, run by the host. See MultiplayerVoice. */
|
|
48
|
+
readonly voice: MultiplayerVoice;
|
|
46
49
|
leave(): void;
|
|
47
50
|
}
|
|
48
51
|
export interface CastleMultiplayerApi {
|
package/dist/multiplayer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CastleError } from "./errors";
|
|
2
2
|
import { BINARY_MESSAGE_TAG, isBinaryPayload, MAX_CLIENT_FRAME_BYTES, toUint8Array, } from "./multiplayerProtocol";
|
|
3
3
|
import { hostRequest } from "./transport";
|
|
4
|
+
import { MultiplayerVoiceImpl } from "./multiplayerVoice";
|
|
4
5
|
const KEEPALIVE_INTERVAL_MS = 25_000;
|
|
5
6
|
const HELLO_TIMEOUT_MS = 10_000;
|
|
6
7
|
const RECONNECT_BUDGET_MS = 30_000;
|
|
@@ -39,6 +40,11 @@ class MultiplayerConnectionImpl {
|
|
|
39
40
|
messages = new Set();
|
|
40
41
|
rosters = new Set();
|
|
41
42
|
states = new Set();
|
|
43
|
+
voiceImpl = new MultiplayerVoiceImpl({
|
|
44
|
+
sessionId: () => this.currentSessionId,
|
|
45
|
+
players: () => this.roster?.players ?? [],
|
|
46
|
+
isSolo: () => this.solo,
|
|
47
|
+
});
|
|
42
48
|
constructor(params, onClosed) {
|
|
43
49
|
this.params = params;
|
|
44
50
|
this.onClosed = onClosed;
|
|
@@ -58,6 +64,9 @@ class MultiplayerConnectionImpl {
|
|
|
58
64
|
get soloReason() {
|
|
59
65
|
return this.currentSoloReason;
|
|
60
66
|
}
|
|
67
|
+
get voice() {
|
|
68
|
+
return this.voiceImpl;
|
|
69
|
+
}
|
|
61
70
|
async start() {
|
|
62
71
|
try {
|
|
63
72
|
await this.acquire();
|
|
@@ -171,6 +180,7 @@ class MultiplayerConnectionImpl {
|
|
|
171
180
|
};
|
|
172
181
|
this.setRoster({ players: [you], you });
|
|
173
182
|
this.transition("connected");
|
|
183
|
+
void this.voiceImpl.attach();
|
|
174
184
|
}
|
|
175
185
|
openSocket(credential, value) {
|
|
176
186
|
const attempt = ++this.socketAttempt;
|
|
@@ -253,6 +263,7 @@ class MultiplayerConnectionImpl {
|
|
|
253
263
|
this.reAcquisitionUsed = false;
|
|
254
264
|
this.transition("connected");
|
|
255
265
|
this.startKeepalive();
|
|
266
|
+
void this.voiceImpl.attach();
|
|
256
267
|
}
|
|
257
268
|
handleFrame(frame) {
|
|
258
269
|
switch (frame.t) {
|
|
@@ -293,6 +304,7 @@ class MultiplayerConnectionImpl {
|
|
|
293
304
|
}
|
|
294
305
|
setRoster(roster) {
|
|
295
306
|
this.roster = roster;
|
|
307
|
+
this.voiceImpl.rosterChanged();
|
|
296
308
|
for (const callback of this.rosters)
|
|
297
309
|
callListener(callback, roster);
|
|
298
310
|
}
|
|
@@ -391,6 +403,7 @@ class MultiplayerConnectionImpl {
|
|
|
391
403
|
this.socketAttempt += 1;
|
|
392
404
|
closeSocket(socket, 1000, "session closed");
|
|
393
405
|
this.currentState = "closed";
|
|
406
|
+
this.voiceImpl.dispose();
|
|
394
407
|
for (const callback of this.states)
|
|
395
408
|
callStateListener(callback, "closed", metadata);
|
|
396
409
|
this.onClosed();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { VoiceEvent, VoiceJoinStatus, VoiceMixEntry, VoiceState } from "./commands";
|
|
2
|
+
import type { MultiplayerPlayer } from "./multiplayerProtocol";
|
|
3
|
+
export declare const VOICE_MIX_INTERVAL_MS = 100;
|
|
4
|
+
export type { VoiceJoinStatus, VoiceMixEntry, VoiceState };
|
|
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
|
+
onState(callback: (state: VoiceState) => void): () => void;
|
|
22
|
+
/** The playerIds speaking right now; called whenever that set changes. */
|
|
23
|
+
onSpeaking(callback: (playerIds: string[]) => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
export interface VoiceSessionAccess {
|
|
26
|
+
sessionId(): string;
|
|
27
|
+
players(): MultiplayerPlayer[];
|
|
28
|
+
isSolo(): boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare class MultiplayerVoiceImpl implements MultiplayerVoice {
|
|
31
|
+
private readonly access;
|
|
32
|
+
private currentState;
|
|
33
|
+
private voiceSessionId;
|
|
34
|
+
private attaching;
|
|
35
|
+
private hostStateVersion;
|
|
36
|
+
private pendingJoin;
|
|
37
|
+
private joinRequest;
|
|
38
|
+
private speakingUserIds;
|
|
39
|
+
private speaking;
|
|
40
|
+
private pendingMix;
|
|
41
|
+
private sentMix;
|
|
42
|
+
private mixTimer;
|
|
43
|
+
private lastMixSentAt;
|
|
44
|
+
private disposed;
|
|
45
|
+
private readonly states;
|
|
46
|
+
private readonly speakers;
|
|
47
|
+
constructor(access: VoiceSessionAccess);
|
|
48
|
+
get state(): VoiceState;
|
|
49
|
+
attach(): Promise<void>;
|
|
50
|
+
requestJoin(): Promise<VoiceJoinStatus>;
|
|
51
|
+
private askToJoin;
|
|
52
|
+
leave(): void;
|
|
53
|
+
setMix(mix: Record<string, VoiceMixEntry>): void;
|
|
54
|
+
onState(callback: (state: VoiceState) => void): () => void;
|
|
55
|
+
onSpeaking(callback: (playerIds: string[]) => void): () => void;
|
|
56
|
+
receive(event: VoiceEvent): void;
|
|
57
|
+
rosterChanged(): void;
|
|
58
|
+
dispose(): void;
|
|
59
|
+
private isCurrent;
|
|
60
|
+
private withdraw;
|
|
61
|
+
private endSession;
|
|
62
|
+
private sendLeave;
|
|
63
|
+
private setState;
|
|
64
|
+
private emitSpeaking;
|
|
65
|
+
private scheduleMix;
|
|
66
|
+
private flushMix;
|
|
67
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
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
|
+
mixTimer = null;
|
|
35
|
+
lastMixSentAt = 0;
|
|
36
|
+
disposed = false;
|
|
37
|
+
states = new Set();
|
|
38
|
+
speakers = new Set();
|
|
39
|
+
constructor(access) {
|
|
40
|
+
this.access = access;
|
|
41
|
+
}
|
|
42
|
+
get state() {
|
|
43
|
+
return this.currentState;
|
|
44
|
+
}
|
|
45
|
+
// Called by the connection whenever it (re)gains a session. A reconnect keeps the
|
|
46
|
+
// session; a re-acquisition may land in a different one, or in solo, and voice for
|
|
47
|
+
// the old session ends with it. Asks the host where voice stands; a host without
|
|
48
|
+
// voice (an older app, local dev) answers with an error or "unavailable".
|
|
49
|
+
attach() {
|
|
50
|
+
if (this.disposed)
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
setActiveVoice(this);
|
|
53
|
+
setVoiceEventHandler(dispatchVoiceEvent);
|
|
54
|
+
const sessionId = this.access.isSolo() ? "" : this.access.sessionId();
|
|
55
|
+
if (this.voiceSessionId !== sessionId) {
|
|
56
|
+
this.endSession();
|
|
57
|
+
// The old session's state means nothing for the new one until the host answers.
|
|
58
|
+
this.setState("unavailable");
|
|
59
|
+
}
|
|
60
|
+
this.voiceSessionId = sessionId;
|
|
61
|
+
if (!sessionId) {
|
|
62
|
+
this.setState("unavailable");
|
|
63
|
+
this.attaching = null;
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
const attaching = hostRequest("voice.getState", { sessionId }).then((result) => {
|
|
67
|
+
if (this.isCurrent(sessionId))
|
|
68
|
+
this.setState(result.state);
|
|
69
|
+
}, () => {
|
|
70
|
+
if (this.isCurrent(sessionId))
|
|
71
|
+
this.setState("unavailable");
|
|
72
|
+
});
|
|
73
|
+
this.attaching = attaching;
|
|
74
|
+
return attaching;
|
|
75
|
+
}
|
|
76
|
+
// A second call while the player is still choosing shares the first one's answer.
|
|
77
|
+
requestJoin() {
|
|
78
|
+
if (!this.joinRequest) {
|
|
79
|
+
const request = this.askToJoin().finally(() => {
|
|
80
|
+
if (this.joinRequest === request)
|
|
81
|
+
this.joinRequest = null;
|
|
82
|
+
});
|
|
83
|
+
this.joinRequest = request;
|
|
84
|
+
}
|
|
85
|
+
return this.joinRequest;
|
|
86
|
+
}
|
|
87
|
+
async askToJoin() {
|
|
88
|
+
// Registered before any waiting, so leave() or a session change can withdraw a
|
|
89
|
+
// request that hasn't reached the host yet.
|
|
90
|
+
const pending = new AbortController();
|
|
91
|
+
this.pendingJoin = pending;
|
|
92
|
+
// `join()` resolves at hello, before the host has said whether voice is on. A
|
|
93
|
+
// reconnect can attach again meanwhile; wait for the latest answer.
|
|
94
|
+
let awaited = null;
|
|
95
|
+
while (this.attaching && this.attaching !== awaited) {
|
|
96
|
+
awaited = this.attaching;
|
|
97
|
+
await awaited;
|
|
98
|
+
}
|
|
99
|
+
if (pending.signal.aborted ||
|
|
100
|
+
this.disposed ||
|
|
101
|
+
!this.voiceSessionId ||
|
|
102
|
+
this.currentState === "unavailable" ||
|
|
103
|
+
this.currentState === "live") {
|
|
104
|
+
if (this.pendingJoin === pending)
|
|
105
|
+
this.pendingJoin = null;
|
|
106
|
+
if (pending.signal.aborted || this.currentState !== "live")
|
|
107
|
+
return "unavailable";
|
|
108
|
+
return "joined";
|
|
109
|
+
}
|
|
110
|
+
const sessionId = this.voiceSessionId;
|
|
111
|
+
const version = this.hostStateVersion;
|
|
112
|
+
this.setState("joining");
|
|
113
|
+
let status;
|
|
114
|
+
try {
|
|
115
|
+
({ status } = await hostRequest("voice.requestJoin", { sessionId }, { signal: pending.signal }));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
status = "unavailable";
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
if (this.pendingJoin === pending)
|
|
122
|
+
this.pendingJoin = null;
|
|
123
|
+
}
|
|
124
|
+
if (pending.signal.aborted || !this.isCurrent(sessionId)) {
|
|
125
|
+
// Withdrawn, or the game connection ended, while the player was choosing. The
|
|
126
|
+
// abort already cancelled the request at the host, whose executor undoes a
|
|
127
|
+
// "yes" that lands anyway.
|
|
128
|
+
return "unavailable";
|
|
129
|
+
}
|
|
130
|
+
if (version === this.hostStateVersion) {
|
|
131
|
+
this.setState(status === "joined" ? "live" : status === "unavailable" ? "unavailable" : "off");
|
|
132
|
+
}
|
|
133
|
+
return status;
|
|
134
|
+
}
|
|
135
|
+
leave() {
|
|
136
|
+
if (this.disposed)
|
|
137
|
+
return;
|
|
138
|
+
const inVoice = this.currentState === "live" || this.currentState === "joining";
|
|
139
|
+
if (!inVoice && this.pendingJoin === null)
|
|
140
|
+
return;
|
|
141
|
+
this.withdraw();
|
|
142
|
+
if (inVoice) {
|
|
143
|
+
this.setState("off");
|
|
144
|
+
this.sendLeave(this.voiceSessionId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
setMix(mix) {
|
|
148
|
+
if (this.disposed || !mix || typeof mix !== "object")
|
|
149
|
+
return;
|
|
150
|
+
const players = this.access.players();
|
|
151
|
+
for (const [playerId, entry] of Object.entries(mix)) {
|
|
152
|
+
const player = players.find((candidate) => candidate.playerId === playerId);
|
|
153
|
+
if (!player || !entry || typeof entry !== "object")
|
|
154
|
+
continue;
|
|
155
|
+
const next = { ...this.pendingMix[player.userId] };
|
|
156
|
+
if (typeof entry.gain === "number" && Number.isFinite(entry.gain)) {
|
|
157
|
+
next.gain = clamp(entry.gain, 0, 1);
|
|
158
|
+
}
|
|
159
|
+
if (typeof entry.pan === "number" && Number.isFinite(entry.pan)) {
|
|
160
|
+
next.pan = clamp(entry.pan, -1, 1);
|
|
161
|
+
}
|
|
162
|
+
this.pendingMix[player.userId] = next;
|
|
163
|
+
}
|
|
164
|
+
this.scheduleMix();
|
|
165
|
+
}
|
|
166
|
+
onState(callback) {
|
|
167
|
+
this.states.add(callback);
|
|
168
|
+
callListener(callback, this.currentState);
|
|
169
|
+
return () => this.states.delete(callback);
|
|
170
|
+
}
|
|
171
|
+
onSpeaking(callback) {
|
|
172
|
+
this.speakers.add(callback);
|
|
173
|
+
callListener(callback, this.speaking.slice());
|
|
174
|
+
return () => this.speakers.delete(callback);
|
|
175
|
+
}
|
|
176
|
+
receive(event) {
|
|
177
|
+
if (this.disposed || !this.voiceSessionId || event.sessionId !== this.voiceSessionId) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (event.type === "state") {
|
|
181
|
+
this.hostStateVersion += 1;
|
|
182
|
+
this.setState(event.state);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.speakingUserIds = event.userIds.slice();
|
|
186
|
+
this.emitSpeaking();
|
|
187
|
+
}
|
|
188
|
+
// The roster changed: speakers are re-mapped, and mixes for players who left dropped.
|
|
189
|
+
rosterChanged() {
|
|
190
|
+
if (this.disposed)
|
|
191
|
+
return;
|
|
192
|
+
const userIds = new Set(this.access.players().map((player) => player.userId));
|
|
193
|
+
for (const userId of Object.keys(this.pendingMix)) {
|
|
194
|
+
if (!userIds.has(userId))
|
|
195
|
+
delete this.pendingMix[userId];
|
|
196
|
+
}
|
|
197
|
+
for (const userId of Object.keys(this.sentMix)) {
|
|
198
|
+
if (!userIds.has(userId))
|
|
199
|
+
delete this.sentMix[userId];
|
|
200
|
+
}
|
|
201
|
+
this.emitSpeaking();
|
|
202
|
+
}
|
|
203
|
+
// The game connection ended: voice for its session goes with it.
|
|
204
|
+
dispose() {
|
|
205
|
+
if (this.disposed)
|
|
206
|
+
return;
|
|
207
|
+
this.endSession();
|
|
208
|
+
this.setState("unavailable");
|
|
209
|
+
this.disposed = true;
|
|
210
|
+
if (this.mixTimer !== null)
|
|
211
|
+
clearTimeout(this.mixTimer);
|
|
212
|
+
this.mixTimer = null;
|
|
213
|
+
setActiveVoice(null, this);
|
|
214
|
+
}
|
|
215
|
+
isCurrent(sessionId) {
|
|
216
|
+
return !this.disposed && sessionId !== "" && sessionId === this.voiceSessionId;
|
|
217
|
+
}
|
|
218
|
+
// Cancels a pending request, so a retry starts a fresh prompt.
|
|
219
|
+
withdraw() {
|
|
220
|
+
this.pendingJoin?.abort();
|
|
221
|
+
this.pendingJoin = null;
|
|
222
|
+
this.joinRequest = null;
|
|
223
|
+
}
|
|
224
|
+
// Leaves the current voice session, if the player is in it or being asked.
|
|
225
|
+
endSession() {
|
|
226
|
+
const inVoice = this.currentState === "live" || this.currentState === "joining" || this.pendingJoin !== null;
|
|
227
|
+
this.withdraw();
|
|
228
|
+
if (inVoice && this.voiceSessionId)
|
|
229
|
+
this.sendLeave(this.voiceSessionId);
|
|
230
|
+
this.speakingUserIds = [];
|
|
231
|
+
this.pendingMix = {};
|
|
232
|
+
this.sentMix = {};
|
|
233
|
+
this.emitSpeaking();
|
|
234
|
+
}
|
|
235
|
+
sendLeave(sessionId) {
|
|
236
|
+
void hostRequest("voice.leave", { sessionId }).catch(() => { });
|
|
237
|
+
}
|
|
238
|
+
setState(state) {
|
|
239
|
+
if (this.currentState === state)
|
|
240
|
+
return;
|
|
241
|
+
this.currentState = state;
|
|
242
|
+
if (state !== "live")
|
|
243
|
+
this.speakingUserIds = [];
|
|
244
|
+
this.emitSpeaking();
|
|
245
|
+
for (const callback of this.states)
|
|
246
|
+
callListener(callback, state);
|
|
247
|
+
if (state === "live") {
|
|
248
|
+
// A rejoined call starts from scratch at the host: send the deck's mix again.
|
|
249
|
+
for (const userId of Object.keys(this.sentMix)) {
|
|
250
|
+
this.pendingMix[userId] ??= {};
|
|
251
|
+
}
|
|
252
|
+
this.scheduleMix();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
emitSpeaking() {
|
|
256
|
+
const userIds = new Set(this.speakingUserIds);
|
|
257
|
+
const playerIds = this.access
|
|
258
|
+
.players()
|
|
259
|
+
.filter((player) => userIds.has(player.userId))
|
|
260
|
+
.map((player) => player.playerId)
|
|
261
|
+
.sort();
|
|
262
|
+
if (playerIds.join("\n") === this.speaking.join("\n"))
|
|
263
|
+
return;
|
|
264
|
+
this.speaking = playerIds;
|
|
265
|
+
for (const callback of this.speakers)
|
|
266
|
+
callListener(callback, playerIds.slice());
|
|
267
|
+
}
|
|
268
|
+
scheduleMix() {
|
|
269
|
+
if (this.mixTimer !== null || Object.keys(this.pendingMix).length === 0)
|
|
270
|
+
return;
|
|
271
|
+
const wait = Math.max(0, this.lastMixSentAt + VOICE_MIX_INTERVAL_MS - Date.now());
|
|
272
|
+
this.mixTimer = setTimeout(() => {
|
|
273
|
+
this.mixTimer = null;
|
|
274
|
+
this.flushMix();
|
|
275
|
+
}, wait);
|
|
276
|
+
}
|
|
277
|
+
// Held until the player is in voice, then sent: the latest mix is what matters. Each
|
|
278
|
+
// entry goes out whole, so a field the deck didn't name keeps its last value.
|
|
279
|
+
flushMix() {
|
|
280
|
+
if (this.disposed || !this.voiceSessionId || this.currentState !== "live")
|
|
281
|
+
return;
|
|
282
|
+
const userIds = new Set(this.access.players().map((player) => player.userId));
|
|
283
|
+
const mix = {};
|
|
284
|
+
for (const [userId, entry] of Object.entries(this.pendingMix)) {
|
|
285
|
+
if (!userIds.has(userId))
|
|
286
|
+
continue;
|
|
287
|
+
const previous = this.sentMix[userId] ?? DEFAULT_MIX;
|
|
288
|
+
const full = { gain: entry.gain ?? previous.gain, pan: entry.pan ?? previous.pan };
|
|
289
|
+
this.sentMix[userId] = full;
|
|
290
|
+
mix[userId] = full;
|
|
291
|
+
}
|
|
292
|
+
this.pendingMix = {};
|
|
293
|
+
if (Object.keys(mix).length === 0)
|
|
294
|
+
return;
|
|
295
|
+
this.lastMixSentAt = Date.now();
|
|
296
|
+
void hostRequest("voice.setMix", { sessionId: this.voiceSessionId, mix }).catch(() => { });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function clamp(value, min, max) {
|
|
300
|
+
return Math.min(max, Math.max(min, value));
|
|
301
|
+
}
|
|
302
|
+
function callListener(callback, value) {
|
|
303
|
+
try {
|
|
304
|
+
callback(value);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// One deck listener must not break voice or other listeners.
|
|
308
|
+
}
|
|
309
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CommandName, type CommandParams, type CommandResponseEnvelope } from "./commands";
|
|
1
|
+
import { type CommandName, type CommandParams, type CommandResponseEnvelope, type CommandStreamEnvelope } from "./commands";
|
|
2
2
|
import { type TunedParam } from "./paramsRegistry";
|
|
3
3
|
export declare const CARD_RATIO: number;
|
|
4
4
|
interface LocalResponse {
|
|
@@ -43,7 +43,9 @@ export declare function renameFile(from: string, to: string): Promise<void>;
|
|
|
43
43
|
export declare function deleteFile(path: string): Promise<void>;
|
|
44
44
|
export declare function openFile(path: string): void;
|
|
45
45
|
export declare function initCard(): HTMLDivElement;
|
|
46
|
-
export declare function sendLocalCommand<C extends CommandName>(command: C, params: CommandParams[C]): Promise<
|
|
46
|
+
export declare function sendLocalCommand<C extends CommandName>(requestId: string, command: C, params: CommandParams[C], listener: (message: CommandResponseEnvelope | CommandStreamEnvelope) => void): Promise<void>;
|
|
47
|
+
export declare function cancelLocalCommand(requestId: string): void;
|
|
48
|
+
export declare function releaseLocalCommand(requestId: string): void;
|
|
47
49
|
/**
|
|
48
50
|
* Notified `true` on every fresh dev-server socket and `false` when one is lost.
|
|
49
51
|
* What lets a queued write know the moment retrying is worth it again.
|
package/dist/runtime.js
CHANGED
|
@@ -13,10 +13,9 @@ const pendingRequests = new Map();
|
|
|
13
13
|
// Local-dev command channel: the `castle-web serve` dev server is the host, so
|
|
14
14
|
// SDK commands ride the same websocket runtime.ts already uses for
|
|
15
15
|
// logs/screenshots/restart. Correlated by requestId, separate from the
|
|
16
|
-
// screenshot/write_file request map above.
|
|
17
|
-
const COMMAND_TIMEOUT_MS = 15000;
|
|
16
|
+
// screenshot/write_file request map above. transport.ts owns the timeouts.
|
|
18
17
|
const SOCKET_WAIT_TIMEOUT_MS = 10000;
|
|
19
|
-
const
|
|
18
|
+
const localCommandListeners = new Map();
|
|
20
19
|
const origLog = console.log;
|
|
21
20
|
const origWarn = console.warn;
|
|
22
21
|
const origError = console.error;
|
|
@@ -324,38 +323,42 @@ function sendLocalRequest(msg) {
|
|
|
324
323
|
ws.send(JSON.stringify(request));
|
|
325
324
|
});
|
|
326
325
|
}
|
|
327
|
-
// Send an SDK command to the dev server
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
});
|
|
358
|
-
}
|
|
326
|
+
// Send an SDK command to the dev server. Its stream envelopes and its response
|
|
327
|
+
// go to `listener` as they arrive, and the listener is dropped after the
|
|
328
|
+
// response. transport.ts interprets them, so error reconstruction stays uniform
|
|
329
|
+
// across all three channels there. Waits for the socket to open so a command
|
|
330
|
+
// issued during startup isn't dropped; rejects if it never opens.
|
|
331
|
+
export async function sendLocalCommand(requestId, command, params, listener) {
|
|
332
|
+
localCommandListeners.set(requestId, listener);
|
|
333
|
+
try {
|
|
334
|
+
const socket = await waitForSocket();
|
|
335
|
+
// Cancelled or timed out while the socket was opening.
|
|
336
|
+
if (!localCommandListeners.has(requestId)) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
socket.send(JSON.stringify({
|
|
340
|
+
type: "castle_command",
|
|
341
|
+
castleSdk: CASTLE_SDK_PROTOCOL,
|
|
342
|
+
requestId,
|
|
343
|
+
command,
|
|
344
|
+
params,
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
localCommandListeners.delete(requestId);
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// Ask the dev server to stop a streaming command, and stop listening for it.
|
|
353
|
+
export function cancelLocalCommand(requestId) {
|
|
354
|
+
localCommandListeners.delete(requestId);
|
|
355
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
356
|
+
ws.send(JSON.stringify({ type: "castle_command_cancel", castleSdk: CASTLE_SDK_PROTOCOL, requestId }));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Stop listening for a command the deck has given up on.
|
|
360
|
+
export function releaseLocalCommand(requestId) {
|
|
361
|
+
localCommandListeners.delete(requestId);
|
|
359
362
|
}
|
|
360
363
|
function waitForSocket() {
|
|
361
364
|
if (ws && ws.readyState === WebSocket.OPEN)
|
|
@@ -374,10 +377,15 @@ function waitForSocket() {
|
|
|
374
377
|
}, 100);
|
|
375
378
|
});
|
|
376
379
|
}
|
|
377
|
-
function
|
|
378
|
-
const
|
|
379
|
-
if (
|
|
380
|
-
|
|
380
|
+
function deliverLocalCommand(msg) {
|
|
381
|
+
const listener = localCommandListeners.get(msg.requestId);
|
|
382
|
+
if (!listener) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!("stream" in msg)) {
|
|
386
|
+
localCommandListeners.delete(msg.requestId);
|
|
387
|
+
}
|
|
388
|
+
listener(msg);
|
|
381
389
|
}
|
|
382
390
|
function resolveLocalRequest(msg) {
|
|
383
391
|
if (!msg.requestId)
|
|
@@ -919,7 +927,10 @@ function handleLocalMessage(msg) {
|
|
|
919
927
|
resolveLocalRequest(msg);
|
|
920
928
|
}
|
|
921
929
|
else if (msg.type === "castle_command_response") {
|
|
922
|
-
|
|
930
|
+
deliverLocalCommand(msg);
|
|
931
|
+
}
|
|
932
|
+
else if (msg.type === "castle_command_stream") {
|
|
933
|
+
deliverLocalCommand(msg);
|
|
923
934
|
}
|
|
924
935
|
}
|
|
925
936
|
// Files-changed listeners, registered by consumers (the kit engine, editors).
|
package/dist/server/storage.d.ts
CHANGED
|
@@ -106,6 +106,48 @@ export interface EvaluateCharacterInformationInput {
|
|
|
106
106
|
operationId: string;
|
|
107
107
|
maxDeliveries?: number;
|
|
108
108
|
}
|
|
109
|
+
export type CharacterStoryOpportunity = {
|
|
110
|
+
type: "approach" | "room_entry" | "world_action";
|
|
111
|
+
id: string;
|
|
112
|
+
};
|
|
113
|
+
export interface EvaluateCharacterBeatsInput {
|
|
114
|
+
operationId: string;
|
|
115
|
+
opportunityId: string;
|
|
116
|
+
opportunity: CharacterStoryOpportunity;
|
|
117
|
+
presentCharacterIds: string[];
|
|
118
|
+
}
|
|
119
|
+
export interface CharacterBeatSelection {
|
|
120
|
+
deliveryId: string;
|
|
121
|
+
beatId: string;
|
|
122
|
+
characterId: string;
|
|
123
|
+
openingText: string;
|
|
124
|
+
}
|
|
125
|
+
export interface CharacterBeatEvaluationResult {
|
|
126
|
+
operationId: string;
|
|
127
|
+
opportunityId: string;
|
|
128
|
+
opportunityCount: number;
|
|
129
|
+
gameTime: number | null;
|
|
130
|
+
selection: CharacterBeatSelection | null;
|
|
131
|
+
}
|
|
132
|
+
export type CharacterBeatEvaluationOperation = Omit<CharacterOperation, "result"> & {
|
|
133
|
+
result: CharacterBeatEvaluationResult | null;
|
|
134
|
+
};
|
|
135
|
+
export interface CompleteScheduledCharacterBeatInput {
|
|
136
|
+
operationId: string;
|
|
137
|
+
deliveryId: string;
|
|
138
|
+
outcomeId: string;
|
|
139
|
+
}
|
|
140
|
+
export interface CharacterScheduledBeatCompletionResult {
|
|
141
|
+
completionId: string;
|
|
142
|
+
deliveryId: string;
|
|
143
|
+
beatId: string;
|
|
144
|
+
characterId: string;
|
|
145
|
+
outcomeId: string;
|
|
146
|
+
completedAt: string;
|
|
147
|
+
}
|
|
148
|
+
export type CharacterScheduledBeatCompletionOperation = Omit<CharacterOperation, "result"> & {
|
|
149
|
+
result: CharacterScheduledBeatCompletionResult | null;
|
|
150
|
+
};
|
|
109
151
|
export interface CharacterInformationEvaluationResult {
|
|
110
152
|
generation: number;
|
|
111
153
|
evaluatedAt: string;
|
|
@@ -153,6 +195,8 @@ export interface ServerPlaythroughCharacters {
|
|
|
153
195
|
deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<CharacterOperation>;
|
|
154
196
|
completeBeat(input: CompleteCharacterBeatInput): Promise<CharacterOperation>;
|
|
155
197
|
evaluateInformation(input: EvaluateCharacterInformationInput): Promise<CharacterInformationEvaluationOperation>;
|
|
198
|
+
evaluateBeats(input: EvaluateCharacterBeatsInput): Promise<CharacterBeatEvaluationOperation>;
|
|
199
|
+
completeScheduledBeat(input: CompleteScheduledCharacterBeatInput): Promise<CharacterScheduledBeatCompletionOperation>;
|
|
156
200
|
status(operationId: string): Promise<CharacterOperation>;
|
|
157
201
|
history(options?: ServerCharacterHistoryOptions): Promise<CharacterHistory>;
|
|
158
202
|
}
|